diff --git a/README.md b/README.md index b112b9b..75e3b4c 100644 --- a/README.md +++ b/README.md @@ -321,6 +321,39 @@ release), use `distZip` or `distTar`, which land in archives — pushing a `vX.Y.Z` tag builds and publishes these automatically (see [`.github/workflows/release.yml`](.github/workflows/release.yml)). +### Writing a capability provider (SPI, in transition) + +A capability module (`jircd-capabilities/*`) is a `CapabilityExtension` that owns its behaviour +through typed registrations instead of core branching on its name. Override +`CapabilityExtension.register(CapabilityRegistrar)` to stage hooks; nothing is visible until the +whole descriptor validates and is published, and a failed enable leaves nothing published. + +| Registration | Purpose | +| --- | --- | +| `onOutboundTags(keys, maxBytes, hook)` | Owned server tags for recipients that negotiated the capability; pure in the immutable message, validated against the declared escaped-byte bound | +| `onEvent`, `onInboundTags`, `onRecipients` | Committed-event delivery, client-tag selection, sender-copy roles | +| `onMemberStatus`, `onNamesIdentity`, `onReplyField` | Bounded decorations of schema-declared reply slots | +| `onOutputGroup` | Framing of a finite group, falling back to plain members | +| `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 | + +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 +core state and other providers are unaffected. Hooks must be pure, nonblocking and thread-safe. Core +applies the owner's enabled state and the declared negotiation prerequisites, so a hook never reads +a session's capability set. + +**Status.** Only `server-time` is migrated. The other five providers still use the temporary +`contributeTags` / `includeSenderInFanOut` bridge, which is removed once the last one migrates; +core routes a provider's tags through exactly one path. Runtime FAILED withdrawal of a faulty +provider, event publication and the remaining migrations are later changes in the same feature +(`specs/014-capability-plugin-spi/`). `0.1.0-SNAPSHOT` is unreleased, so the SPI changes carry no +version bump until the first release. + +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 +with the same arithmetic. Until `multi-prefix` declares its own bound the server keeps reserving the +two-symbol status width, so accepted configurations stay accepted. + ### Outbound queue (SendQ) policy Each connection has a bounded outbound queue, measured in bytes of queued wire data (escaped diff --git a/jircd-capabilities/server-time/src/main/java/net/jircd/capabilities/servertime/ServerTimeExtension.java b/jircd-capabilities/server-time/src/main/java/net/jircd/capabilities/servertime/ServerTimeExtension.java index 9ef659f..f3635a6 100644 --- a/jircd-capabilities/server-time/src/main/java/net/jircd/capabilities/servertime/ServerTimeExtension.java +++ b/jircd-capabilities/server-time/src/main/java/net/jircd/capabilities/servertime/ServerTimeExtension.java @@ -18,7 +18,9 @@ import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.Map; +import java.util.Set; import net.jircd.core.extension.AbstractCapabilityExtension; +import net.jircd.core.extension.capability.CapabilityRegistrar; import net.jircd.core.session.OutboundMessage; import net.jircd.protocol.CapabilityName; @@ -31,6 +33,8 @@ public final class ServerTimeExtension extends AbstractCapabilityExtension { public static final String ID = CapabilityName.SERVER_TIME; + private static final String TIME_TAG = "time"; + /** IRCv3 server-time's required format: millisecond-precision, UTC, {@code Z}-suffixed. */ private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").withZone(ZoneOffset.UTC); @@ -39,8 +43,16 @@ public ServerTimeExtension() { super(ID); } + /** {@code time=} plus the 24-byte timestamp; no character in it needs tag escaping. */ + static final long MAX_TAG_BYTES = 29; + @Override - public Map contributeTags(OutboundMessage message) { - return Map.of("time", TIME_FORMAT.format(message.sentAt())); + public void register(CapabilityRegistrar registrar) { + registrar.onOutboundTags(Set.of(TIME_TAG), MAX_TAG_BYTES, ServerTimeExtension::timeTag); + } + + /** Pure in the message's send time; shared by every recipient of the envelope. */ + static Map timeTag(OutboundMessage message) { + return Map.of(TIME_TAG, TIME_FORMAT.format(message.sentAt())); } } diff --git a/jircd-capabilities/server-time/src/test/java/net/jircd/capabilities/servertime/ServerTimeExtensionPurityTest.java b/jircd-capabilities/server-time/src/test/java/net/jircd/capabilities/servertime/ServerTimeExtensionPurityTest.java deleted file mode 100644 index 95d2103..0000000 --- a/jircd-capabilities/server-time/src/test/java/net/jircd/capabilities/servertime/ServerTimeExtensionPurityTest.java +++ /dev/null @@ -1,55 +0,0 @@ -/* - * 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.capabilities.servertime; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.time.Instant; -import java.util.Map; -import java.util.UUID; -import net.jircd.core.session.OutboundMessage; -import org.junit.jupiter.api.Test; - -class ServerTimeExtensionPurityTest { - @Test - void contributionDependsOnlyOnTheMessage() { - var extension = new ServerTimeExtension(); - var message = - new OutboundMessage( - "n!u@h", - "PRIVMSG", - "#c", - "hello", - Map.of(), - Instant.parse("2020-01-02T03:04:05.006Z"), - UUID.fromString("00000000-0000-0000-0000-000000000123")); - var equal = - new OutboundMessage( - message.senderPresentedForm(), - message.command(), - message.target(), - message.body(), - message.clientTags(), - message.sentAt(), - message.messageId()); - for (int i = 0; i < 10; i++) { - assertThat(extension.contributeTags(message)) - .isEqualTo(Map.of("time", "2020-01-02T03:04:05.006Z")); - assertThat(new ServerTimeExtension().contributeTags(equal)) - .isEqualTo(extension.contributeTags(message)); - } - } -} diff --git a/jircd-capabilities/server-time/src/test/java/net/jircd/capabilities/servertime/ServerTimeExtensionTest.java b/jircd-capabilities/server-time/src/test/java/net/jircd/capabilities/servertime/ServerTimeExtensionTest.java new file mode 100644 index 0000000..1e86a38 --- /dev/null +++ b/jircd-capabilities/server-time/src/test/java/net/jircd/capabilities/servertime/ServerTimeExtensionTest.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.capabilities.servertime; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.jircd.core.extension.ExtensionRegistry; +import net.jircd.core.extension.capability.CapabilityDescriptor; +import net.jircd.core.extension.capability.CapabilityRegistrar; +import net.jircd.core.extension.capability.FaultCategory; +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.session.CapabilityTagRenderer; +import net.jircd.core.session.ClientSession; +import net.jircd.core.session.OutboundMessage; +import net.jircd.core.session.RateLimitBucket; +import net.jircd.protocol.CapabilityName; +import net.jircd.protocol.MessageSerializer; +import org.junit.jupiter.api.Test; + +class ServerTimeExtensionTest { + private final List faults = new ArrayList<>(); + private final HookDispatcher dispatcher = + new HookDispatcher( + (provider, kind, category) -> faults.add(provider + "/" + kind + "/" + category)); + + private static OutboundMessage message(Instant sentAt) { + return new OutboundMessage( + "n!u@h", + "PRIVMSG", + "#c", + "hello", + Map.of("+client", "kept"), + sentAt, + UUID.fromString("00000000-0000-0000-0000-000000000123")); + } + + private static HookRegistration hook() { + CapabilityRegistrar registrar = new CapabilityRegistrar("server-time", "server-time"); + new ServerTimeExtension().register(registrar); + CapabilityDescriptor descriptor = registrar.build(); + assertThat(descriptor.registrations()).hasSize(1); + return descriptor.registrations().getFirst(); + } + + @Test + void registersOneOwnedTimeTagWithADeclaredBound() { + HookRegistration hook = hook(); + assertThat(hook.kind()).isEqualTo(HookKind.OUTBOUND_TAGS); + assertThat(hook.ownedKeys()).containsExactly("time"); + assertThat(hook.requiresNegotiated()).isEmpty(); + } + + @Test + void contributesTheEnvelopeSendTimeAsAnExactTimestampTag() { + Map tags = + dispatcher.outboundTags(hook(), message(Instant.parse("2020-01-02T03:04:05.006Z"))); + assertThat(tags).containsExactly(Map.entry("time", "2020-01-02T03:04:05.006Z")); + assertThat(faults).isEmpty(); + } + + @Test + void contributionIsPureAndIndependentOfRecipientAndWallClock() { + OutboundMessage first = message(Instant.parse("2020-01-02T03:04:05.006Z")); + OutboundMessage equal = message(Instant.parse("2020-01-02T03:04:05.006Z")); + HookRegistration hook = hook(); + for (int i = 0; i < 10; i++) { + assertThat(dispatcher.outboundTags(hook, first)) + .isEqualTo(dispatcher.outboundTags(hook(), equal)) + .containsEntry("time", "2020-01-02T03:04:05.006Z"); + } + } + + @Test + void contributionIsImmutableAndWithinItsDeclaredBoundAtTheExtremes() { + HookRegistration hook = hook(); + for (Instant extreme : + List.of( + Instant.EPOCH, + Instant.parse("2000-01-01T00:00:00.000Z"), + Instant.parse("9999-12-31T23:59:59.999Z"))) { + Map tags = dispatcher.outboundTags(hook, message(extreme)); + assertThat(MessageSerializer.tagDataBytes(tags)).isLessThanOrEqualTo((int) hook.maxBytes()); + assertThat(tags).hasSize(1); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> tags.put("x", "y")) + .isInstanceOf(UnsupportedOperationException.class); + } + assertThat(faults).isEmpty(); + } + + @Test + void aFailureIsContainedAndYieldsNoTagInsteadOfPropagating() { + assertThat(dispatcher.outboundTags(hook(), message(null))).isEmpty(); + assertThat(faults).containsExactly("server-time/OUTBOUND_TAGS/" + FaultCategory.EXCEPTION); + } + + @Test + void legacyHookIsNoLongerUsedByTheProvider() { + assertThat(new ServerTimeExtension().contributeTags(message(Instant.EPOCH))).isEmpty(); + } + + @Test + void decoratesWithoutMessageTagsNegotiatedOrEnabled() { + ExtensionRegistry registry = new ExtensionRegistry(); + registry.register(new ServerTimeExtension()); + registry.enable(ServerTimeExtension.ID); + ClientSession recipient = new ClientSession("r", "host", RateLimitBucket.withDefaults()); + recipient.replaceNegotiatedCapabilities(List.of(CapabilityName.SERVER_TIME)); + Map tags = + new CapabilityTagRenderer(registry) + .render(recipient, message(Instant.parse("2021-05-06T07:08:09.010Z"))); + assertThat(tags).containsExactly(Map.entry("time", "2021-05-06T07:08:09.010Z")); + ClientSession without = new ClientSession("w", "host", RateLimitBucket.withDefaults()); + assertThat(new CapabilityTagRenderer(registry).render(without, message(Instant.EPOCH))) + .isEmpty(); + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/config/ConfigurationLoader.java b/jircd-core/src/main/java/net/jircd/core/config/ConfigurationLoader.java index 3a9d873..4cac2a9 100644 --- a/jircd-core/src/main/java/net/jircd/core/config/ConfigurationLoader.java +++ b/jircd-core/src/main/java/net/jircd/core/config/ConfigurationLoader.java @@ -28,6 +28,8 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Supplier; +import net.jircd.core.extension.capability.ReplyBudgetCatalog; import net.jircd.protocol.MessageSerializer; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.error.MarkedYAMLException; @@ -48,10 +50,36 @@ public final class ConfigurationLoader { private final Set knownCapabilityIds; private final Set knownServerExtensionIds; + private final Supplier discoveredReplyCatalog; public ConfigurationLoader(Set knownCapabilityIds, Set knownServerExtensionIds) { + this(knownCapabilityIds, knownServerExtensionIds, ReplyBudgetCatalog::empty); + } + + /** + * {@code discoveredReplyCatalog} supplies the reply decorations every installed provider could + * apply, so startup and reload reserve them whether or not the provider is enabled now. + */ + public ConfigurationLoader( + Set knownCapabilityIds, + Set knownServerExtensionIds, + Supplier discoveredReplyCatalog) { this.knownCapabilityIds = knownCapabilityIds; this.knownServerExtensionIds = knownServerExtensionIds; + this.discoveredReplyCatalog = discoveredReplyCatalog; + } + + /** + * Checks the reply budgets against the running server prefix and the widths of every installed + * provider. Startup, reload and runtime enable all use this same bound arithmetic. + */ + public void checkReplyBudgets(String serverName, int nicknameMaxLength, int channelNameMaxLength) + throws ConfigurationException { + validateReplyBudgets( + serverName, + nicknameMaxLength, + channelNameMaxLength, + ReplyBudgetCatalog.legacyCompatible().plus(discoveredReplyCatalog.get())); } public ServerConfiguration load(InputStream yamlInput) throws ConfigurationException { @@ -87,7 +115,7 @@ public ServerConfiguration load(InputStream yamlInput) throws ConfigurationExcep "channelNameMaxLength", ServerConfiguration.DEFAULT_CHANNEL_NAME_MAX_LENGTH, ServerConfiguration.LENGTH_CEILING); - validateReplyBudgets(serverName, nicknameMaxLength, channelNameMaxLength); + checkReplyBudgets(serverName, nicknameMaxLength, channelNameMaxLength); int topicMaxLength = positiveIntWithinCeiling( @@ -242,8 +270,26 @@ private static List parseConnectionLimitO static void validateReplyBudgets( String serverName, int nicknameMaxLength, int channelNameMaxLength) throws ConfigurationException { - validateNamesBudget(serverName, nicknameMaxLength, channelNameMaxLength); - validateHostmaskBudget(serverName, nicknameMaxLength, channelNameMaxLength); + validateReplyBudgets( + serverName, nicknameMaxLength, channelNameMaxLength, ReplyBudgetCatalog.legacyCompatible()); + } + + static void validateReplyBudgets( + String serverName, + int nicknameMaxLength, + int channelNameMaxLength, + ReplyBudgetCatalog catalog) + throws ConfigurationException { + try { + validateNamesBudget(serverName, nicknameMaxLength, channelNameMaxLength, catalog); + validateHostmaskBudget(serverName, nicknameMaxLength, channelNameMaxLength, catalog); + } catch (IllegalArgumentException e) { + // A provider's reply bound that fails or leaves 0..512 at these limits. + throw new ConfigurationException( + "A provider reply bound is invalid for the configured serverName, nicknameMaxLength and" + + " channelNameMaxLength: " + + e.getMessage()); + } } /** @@ -253,9 +299,20 @@ static void validateReplyBudgets( static void validateHostmaskBudget( String serverName, int nicknameMaxLength, int channelNameMaxLength) throws ConfigurationException { + validateHostmaskBudget( + serverName, nicknameMaxLength, channelNameMaxLength, ReplyBudgetCatalog.legacyCompatible()); + } + + static void validateHostmaskBudget( + String serverName, + int nicknameMaxLength, + int channelNameMaxLength, + ReplyBudgetCatalog catalog) + throws ConfigurationException { int serverBytes = MessageSerializer.utf8Bytes(serverName); Map worst = - HostmaskReplyBudget.worstCaseBytes(serverBytes, nicknameMaxLength, channelNameMaxLength); + HostmaskReplyBudget.worstCaseBytes( + serverBytes, nicknameMaxLength, channelNameMaxLength, catalog); int maximum = MessageSerializer.MAX_COMMAND_BYTES; if (worst.values().stream().noneMatch(bytes -> bytes > maximum)) { return; @@ -290,15 +347,29 @@ static void validateHostmaskBudget( static void validateNamesBudget( String serverName, int nicknameMaxLength, int channelNameMaxLength) throws ConfigurationException { + validateNamesBudget( + serverName, nicknameMaxLength, channelNameMaxLength, ReplyBudgetCatalog.legacyCompatible()); + } + + static void validateNamesBudget( + String serverName, + int nicknameMaxLength, + int channelNameMaxLength, + ReplyBudgetCatalog catalog) + throws ConfigurationException { int serverBytes = MessageSerializer.utf8Bytes(serverName); - int namesBytes = serverBytes + 2 * nicknameMaxLength + channelNameMaxLength + 15; - int endBytes = serverBytes + nicknameMaxLength + channelNameMaxLength + 29; + Map names = + HostmaskReplyBudget.namesWorstCaseBytes( + serverBytes, nicknameMaxLength, channelNameMaxLength, catalog); + int namesBytes = names.get("353"); + int endBytes = names.get("366"); int maximum = MessageSerializer.MAX_COMMAND_BYTES; if (namesBytes > maximum || endBytes > maximum) { + // Both numerics grow one byte per channel byte, so the channel budget is what is left. int channelBudget = Math.min( - maximum - serverBytes - 2 * nicknameMaxLength - 15, - maximum - serverBytes - nicknameMaxLength - 29); + maximum - (namesBytes - channelNameMaxLength), + maximum - (endBytes - channelNameMaxLength)); throw new ConfigurationException( "serverName (S=" + serverBytes 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 ea83706..c6913a4 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 @@ -66,13 +66,25 @@ public Path configPath() { * lengths happen as one ordered operation per reload. Listener settings (including backlog) are * validated and published here, but sockets are only bound at application startup; applying * listener changes requires a server restart. + * + * @throws IllegalStateException if called from inside a provider lifecycle callback ({@code + * start}, {@code stop}, {@code register}), which would wait on the lifecycle lock its own + * thread holds */ - public synchronized ServerConfiguration reload() throws ConfigurationException, IOException { + public ServerConfiguration reload() 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 { ServerConfiguration reloaded; try (InputStream in = new FileInputStream(configPath.toFile())) { reloaded = loader.load(in); } - ConfigurationLoader.validateReplyBudgets( + loader.checkReplyBudgets( runningServerName, reloaded.nicknameMaxLength(), reloaded.channelNameMaxLength()); reconcile(reloaded); this.current = reloaded; @@ -98,10 +110,11 @@ private void reconcileSection(java.util.Map desiredStates) { try { if (desiredEnabled && !currentlyEnabled) { extensionRegistry.enable(id); - } else if (!desiredEnabled && currentlyEnabled) { + } else if (!desiredEnabled) { + // Idempotent; for a FAILED provider it retries the cleanup that is still pending. extensionRegistry.disable(id); } - } catch (RuntimeException e) { + } catch (RuntimeException | LinkageError e) { LOG.warn("Failed to reconcile extension '{}' to desired state {}", id, desiredEnabled, e); } } diff --git a/jircd-core/src/main/java/net/jircd/core/config/HostmaskReplyBudget.java b/jircd-core/src/main/java/net/jircd/core/config/HostmaskReplyBudget.java index b0d0a13..c02c1e7 100644 --- a/jircd-core/src/main/java/net/jircd/core/config/HostmaskReplyBudget.java +++ b/jircd-core/src/main/java/net/jircd/core/config/HostmaskReplyBudget.java @@ -17,6 +17,10 @@ import java.util.LinkedHashMap; import java.util.Map; +import net.jircd.core.extension.capability.ReplyBudgetCatalog; +import net.jircd.core.extension.capability.ReplyLayout; +import net.jircd.core.extension.capability.ReplyLimits; +import net.jircd.protocol.MessageSerializer; /** * Worst-case wire size, including CRLF and excluding message tags, of every server reply that @@ -36,21 +40,33 @@ public final class HostmaskReplyBudget { /** Epoch seconds are 10 digits until the year 2286. */ private static final int TIMESTAMP_BYTES = 10; - /** A WHO flags field at its widest: away, operator and both channel statuses (multi-prefix). */ - private static final int WHO_FLAGS_BYTES = 4; - private HostmaskReplyBudget() {} - /** Worst-case bytes per reply, keyed by a label naming it; callers compare to the limit. */ + /** + * Worst-case bytes per reply under the widths reserved before providers declared theirs; callers + * compare to the limit. + */ public static Map worstCaseBytes( int serverBytes, int nicknameMax, int channelMax) { + return worstCaseBytes( + serverBytes, nicknameMax, channelMax, ReplyBudgetCatalog.legacyCompatible()); + } + + /** + * Worst-case bytes per reply, keyed by a label naming it. The channel WHO entry follows the + * decorations in {@code catalog}; every other entry has core-owned fixed fields. + */ + public static Map worstCaseBytes( + int serverBytes, int nicknameMax, int channelMax, ReplyBudgetCatalog catalog) { int s = serverBytes; int n = nicknameMax; int c = channelMax; int hostmask = n + U + H + 2; Map worst = new LinkedHashMap<>(); // ":S 352 N C U H S N F :0 " + CRLF: the server name appears twice. - worst.put("352 (WHO)", 2 * s + 2 * n + c + U + H + 13 + WHO_FLAGS_BYTES + 5); + worst.put( + "352 (WHO)", + Math.toIntExact(catalog.worstCaseBytes(ReplyLayout.WHO_ENTRY, ReplyLimits.of(s, n, c)))); // ":S 333 N C nick!user@host ts" + CRLF worst.put("333 (TOPICWHOTIME)", s + n + c + hostmask + TIMESTAMP_BYTES + 9 + 2); // ":S 311 N nick user host * :" + CRLF; 314 (WHOWAS) has the same shape @@ -61,4 +77,45 @@ public static Map worstCaseBytes( worst.put("INVITE", hostmask + n + c + 12); return worst; } + + /** + * Worst-case bytes of the NAMES member and closing numerics, keyed by numeric, under {@code + * catalog}'s status and identity decorations. + */ + public static Map namesWorstCaseBytes( + int serverBytes, int nicknameMax, int channelMax, ReplyBudgetCatalog catalog) { + ReplyLimits limits = ReplyLimits.of(serverBytes, nicknameMax, channelMax); + Map worst = new LinkedHashMap<>(); + worst.put("353", Math.toIntExact(catalog.worstCaseBytes(ReplyLayout.NAMES_MEMBER, limits))); + worst.put("366", Math.toIntExact(catalog.worstCaseBytes(ReplyLayout.NAMES_END, limits))); + return worst; + } + + /** + * Checks every reply this class and NAMES cover against the 512-byte command limit. + * + * @throws IllegalArgumentException naming each reply that does not fit + */ + public static void requireFits( + int serverBytes, int nicknameMax, int channelMax, ReplyBudgetCatalog catalog) { + Map all = new LinkedHashMap<>(); + all.putAll(namesWorstCaseBytes(serverBytes, nicknameMax, channelMax, catalog)); + all.putAll(worstCaseBytes(serverBytes, nicknameMax, channelMax, catalog)); + StringBuilder over = new StringBuilder(); + all.forEach( + (reply, bytes) -> { + if (bytes > MessageSerializer.MAX_COMMAND_BYTES) { + if (!over.isEmpty()) over.append(", "); + over.append(reply).append('=').append(bytes); + } + }); + if (!over.isEmpty()) { + throw new IllegalArgumentException( + "Provider reply decorations exceed the " + + MessageSerializer.MAX_COMMAND_BYTES + + "-byte reply limit for the configured serverName/nicknameMaxLength/" + + "channelNameMaxLength: " + + over); + } + } } diff --git a/jircd-core/src/main/java/net/jircd/core/extension/AbstractCapabilityExtension.java b/jircd-core/src/main/java/net/jircd/core/extension/AbstractCapabilityExtension.java index b2147e6..7081bb7 100644 --- a/jircd-core/src/main/java/net/jircd/core/extension/AbstractCapabilityExtension.java +++ b/jircd-core/src/main/java/net/jircd/core/extension/AbstractCapabilityExtension.java @@ -18,10 +18,11 @@ import net.jircd.core.capability.Capability; /** - * The {@code id}/{@code providedCapability}/{@code start}/{@code stop} boilerplate every Story 2 - * {@link CapabilityExtension} (message-tags, server-time, echo-message) shares — none of them need - * {@link ServerContext} or hold any state, so a concrete extension only needs to override whichever - * of {@link #contributeTags}/{@link #includeSenderInFanOut} applies to it. + * The {@code id}/{@code providedCapability} boilerplate every {@link CapabilityExtension} shares, + * with documented no-op lifecycle defaults: {@link #start} and {@link #stop} do nothing, because a + * provider that holds no resources has nothing to prepare or release. A migrated provider overrides + * {@link #register} to stage its typed hooks; a provider still on the temporary bridge overrides + * {@link #contributeTags}/{@link #includeSenderInFanOut} instead. */ public abstract class AbstractCapabilityExtension implements CapabilityExtension { @@ -43,9 +44,11 @@ public final Capability providedCapability() { return capability; } + /** No-op: nothing to prepare. Acquire resources here, never in {@link #register}. */ @Override public void start(ServerContext context) {} + /** No-op: nothing to release. Runs after the provider's hooks are withdrawn. */ @Override public void stop() {} } diff --git a/jircd-core/src/main/java/net/jircd/core/extension/CapabilityExtension.java b/jircd-core/src/main/java/net/jircd/core/extension/CapabilityExtension.java index 6e094b7..588800b 100644 --- a/jircd-core/src/main/java/net/jircd/core/extension/CapabilityExtension.java +++ b/jircd-core/src/main/java/net/jircd/core/extension/CapabilityExtension.java @@ -17,6 +17,7 @@ import java.util.Map; import net.jircd.core.capability.Capability; +import net.jircd.core.extension.capability.CapabilityRegistrar; import net.jircd.core.session.ClientSession; import net.jircd.core.session.OutboundMessage; @@ -29,10 +30,28 @@ public interface CapabilityExtension extends Extension { Capability providedCapability(); /** - * Tags this capability contributes to a delivered message, for a recipient that has this - * capability negotiated (T088/T089) — the caller ({@code CapabilityTagRenderer}) checks that - * precondition before calling this; implementations don't re-check it themselves. Extensions that - * don't decorate tags (e.g. {@code echo-message}) leave this at its no-op default. + * Stages this provider's typed hooks. Called once per enable after {@link #start}, and also + * before any enable to discover the reply decorations the provider could apply, so it must have + * no side effects and acquire no resources; those belong in {@code start}. Nothing staged is + * visible until the descriptor is published, and an exception here fails the provider with + * nothing published. + * + *

Providers that have not migrated stage nothing and keep the legacy {@link #contributeTags} + * and {@link #includeSenderInFanOut} hooks. Core routes a provider's tags through exactly one + * path: a provider that publishes any hooks is never also asked for {@link #contributeTags}. + * + *

Runs while the registry's lifecycle lock is held, so it must not call {@code + * ExtensionRegistry.enable/disable} or {@code ConfigurationReloader.reload} (they throw {@link + * IllegalStateException}). + */ + default void register(CapabilityRegistrar registrar) {} + + /** + * Temporary bridge, removed once every provider has migrated. Tags this capability + * contributes to a delivered message, for a recipient that has this capability negotiated + * (T088/T089) — the caller ({@code CapabilityTagRenderer}) checks that precondition before + * calling this; implementations don't re-check it themselves. Extensions that don't decorate tags + * (e.g. {@code echo-message}) leave this at its no-op default. * *

This is a pure function of the immutable message: equal message inputs produce equal maps, * independently of wall clock, randomness, recipient or mutable external state. Identifiers and diff --git a/jircd-core/src/main/java/net/jircd/core/extension/CommandRegistrar.java b/jircd-core/src/main/java/net/jircd/core/extension/CommandRegistrar.java index e76382c..a2f0042 100644 --- a/jircd-core/src/main/java/net/jircd/core/extension/CommandRegistrar.java +++ b/jircd-core/src/main/java/net/jircd/core/extension/CommandRegistrar.java @@ -25,8 +25,18 @@ * depends on extension modules at runtime ({@code ServiceLoader}), so it can't register their * handlers itself; the extension does it, given this at {@link Extension#start}. */ -@FunctionalInterface public interface CommandRegistrar { void register(Command command, CommandHandler handler); + + /** Whether {@code command} already has a handler (core's, or another extension's). */ + boolean isRegistered(Command command); + + /** + * Removes {@code installed} for {@code command}, but only if it is still the handler in place: a + * handler another extension has since installed over it stays. The command is answered as unknown + * once nothing handles it. There is deliberately no default, so a registrar cannot claim to + * support installing handlers it can never take back. + */ + void unregister(Command command, CommandHandler installed); } diff --git a/jircd-core/src/main/java/net/jircd/core/extension/Extension.java b/jircd-core/src/main/java/net/jircd/core/extension/Extension.java index 69b7597..2028304 100644 --- a/jircd-core/src/main/java/net/jircd/core/extension/Extension.java +++ b/jircd-core/src/main/java/net/jircd/core/extension/Extension.java @@ -29,7 +29,21 @@ enum State { String id(); + /** + * Prepares resources. If this throws, {@link #stop()} is still called so whatever was acquired + * before the failure is released; {@code stop} must therefore tolerate a partial start. + * + *

Runs while the registry's lifecycle lock is held: do not call {@code + * ExtensionRegistry.enable/disable} or {@code ConfigurationReloader.reload} from here (directly + * or by waiting on another thread that does); they fail fast with {@link IllegalStateException} + * on the calling thread and would deadlock from another. + */ void start(ServerContext context); + /** + * Releases everything {@link #start} acquired. Must tolerate a partial start and being called + * again after a failure (a failed disable is retried). The same lifecycle-lock rule as {@link + * #start} applies. + */ void stop(); } 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 b295ae0..f65df31 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 @@ -15,14 +15,36 @@ */ package net.jircd.core.extension; +import java.util.ArrayList; import java.util.Collection; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.ServiceLoader; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; import net.jircd.core.capability.Capability; +import net.jircd.core.config.HostmaskReplyBudget; +import net.jircd.core.extension.capability.CapabilityDescriptor; +import net.jircd.core.extension.capability.CapabilityRegistrar; +import net.jircd.core.extension.capability.CapabilityRuntime; +import net.jircd.core.extension.capability.CapabilityServices; +import net.jircd.core.extension.capability.FaultReporter; +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.ReplyBudgetCatalog; +import net.jircd.core.extension.capability.ReplyLimits; +import net.jircd.core.extension.capability.RuntimeSnapshot; import net.jircd.core.session.ChannelMode; +import net.jircd.core.session.ClientSession; import net.jircd.core.session.UserMode; +import net.jircd.core.session.command.CommandHandler; +import net.jircd.core.session.command.Replies; +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; @@ -49,6 +71,9 @@ private static final class Entry { final Extension extension; volatile Extension.State state = Extension.State.DISABLED; + /** A FAILED provider whose cleanup did not finish; a later {@code disable} retries it. */ + volatile boolean cleanupPending; + Entry(Extension extension) { this.extension = extension; } @@ -60,12 +85,92 @@ private static final class Entry { private volatile SupportedFeatures supportedFeatures = SupportedFeatures.defaults(); + private final CapabilityRuntime capabilityRuntime = new CapabilityRuntime(); + private final HookDispatcher hookDispatcher = new HookDispatcher(FaultReporter.logging()); + + /** + * 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. + */ + 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. + */ + 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) {} + + /** The currently published capability hooks; read-only, lifecycle stays with this registry. */ + public RuntimeSnapshot capabilitySnapshot() { + return capabilityRuntime.snapshot(); + } + + /** The handle {@link CapabilityExtension}s receive through {@link ServerContext}. */ + public CapabilityServices capabilityServices() { + return new CapabilityServices(capabilityRuntime::snapshot); + } + + /** The containing dispatcher every capability hook call goes through. */ + public HookDispatcher hookDispatcher() { + return hookDispatcher; + } + + /** + * The reply decorations of every discovered provider that stages validly, enabled or not, so a + * later toggle cannot invalidate a configuration accepted now. Providers that fail to stage are + * left out here and are rejected when enabled. + */ + public ReplyBudgetCatalog discoveredReplyBudgetCatalog() { + List descriptors = new ArrayList<>(); + for (Entry entry : byId.values()) { + if (entry.extension instanceof CapabilityExtension capability) { + try { + descriptors.add(stage(capability)); + } catch (RuntimeException | LinkageError _) { + // Reported with the provider's id when it is enabled. + } + } + } + return ReplyBudgetCatalog.fromDescriptors(descriptors); + } + + private static CapabilityDescriptor stage(CapabilityExtension capability) { + CapabilityRegistrar registrar = + new CapabilityRegistrar(capability.id(), capability.providedCapability().name()); + try { + capability.register(registrar); + return registrar.build(); + } finally { + registrar.close(); + } + } + /** * {@link ServerContext} itself references this registry, so the two are constructed independently * and wired together here rather than via constructor injection. */ public void attachContext(ServerContext serverContext) { this.serverContext = serverContext; + refreshReplyLimits(); + } + + /** Slot bounds are evaluated against the validated configuration, as startup and enable do. */ + private void refreshReplyLimits() { + 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())); } /** @@ -86,7 +191,10 @@ public void register(Extension extension) { } } } - byId.putIfAbsent(extension.id(), new Entry(extension)); + Entry existing = byId.putIfAbsent(extension.id(), new Entry(extension)); + if (existing != null && existing.extension != extension) { + throw new IllegalArgumentException("Duplicate extension id: " + extension.id()); + } } /** @@ -94,8 +202,18 @@ public void register(Extension extension) { * {@link ServiceLoader}. */ public void discover(ClassLoader classLoader) { - ServiceLoader.load(CapabilityExtension.class, classLoader).forEach(this::register); - ServiceLoader.load(ServerExtension.class, classLoader).forEach(this::register); + ServiceLoader.load(CapabilityExtension.class, classLoader).forEach(this::registerDiscovered); + ServiceLoader.load(ServerExtension.class, classLoader).forEach(this::registerDiscovered); + } + + /** A duplicate id is reported and skipped; it must not hide the extensions after it. */ + private void registerDiscovered(Extension extension) { + try { + register(extension); + } catch (IllegalArgumentException e) { + LOG.warn( + "Ignoring discovered extension {}: {}", extension.getClass().getName(), e.getMessage()); + } } public Extension.State stateOf(String id) { @@ -119,8 +237,35 @@ public Collection enabled() { .toList(); } - /** Enables the named extension, rejecting a conflicting {@code extensionPoint} claim (FR-012). */ - public synchronized void enable(String id) { + /** + * Enables the named extension, rejecting a conflicting {@code extensionPoint} claim (FR-012). + * Provider callbacks run under the lifecycle lock only, never the registry monitor. + */ + public void enable(String id) { + requireNoLifecycleCallback(); + lifecycle.lock(); + try { + enableLocked(id); + } finally { + lifecycle.unlock(); + } + } + + /** + * Provider callbacks run with the lifecycle lock held, so they must not start another lifecycle + * change or a configuration reload: it would queue behind (or deadlock with) the lock they hold. + * + * @throws IllegalStateException if the calling thread is inside a lifecycle callback + */ + public void requireNoLifecycleCallback() { + if (lifecycle.isHeldByCurrentThread()) { + throw new IllegalStateException( + "Extension lifecycle changes and reloads cannot be requested from a provider lifecycle" + + " callback (start, stop, register)"); + } + } + + private void enableLocked(String id) { Entry entry = byId.get(id); if (entry == null) { throw new IllegalArgumentException("Unknown extension id: " + id); @@ -142,34 +287,229 @@ public synchronized void enable(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. + if (entry.cleanupPending) throwIfPresent(cleanUp(entry)); + boolean enabled = false; try { entry.extension.start(serverContext); + if (entry.extension instanceof CapabilityExtension capability) publish(capability); entry.state = Extension.State.ENABLED; + entry.cleanupPending = false; if (point != null) { extensionPointOwners.put(point, id); } - } catch (RuntimeException e) { - entry.state = Extension.State.FAILED; - LOG.warn("Extension '{}' failed to start", id, e); - throw e; + enabled = true; + } finally { + if (!enabled) { + // Whatever failed, including an Error: a start that threw may have acquired resources + // 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); + // A claim is only kept while cleanup is unfinished. + if (!entry.cleanupPending && point != null) extensionPointOwners.remove(point, id); + } } } - /** Disables the named extension, releasing any {@code extensionPoint} it held. */ - public synchronized void disable(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. + */ + 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); + } + } + } + + private static List commandsOf(CapabilityDescriptor descriptor) { + return descriptor.registrations().stream() + .filter(registration -> registration.kind() == HookKind.COMMAND) + .toList(); + } + + /** A provider may add commands but never replace one core or another extension owns. */ + private void requireUnownedCommands(List commands) { + if (serverContext == null) return; + for (HookRegistration registration : commands) { + if (serverContext.commandRegistrar().isRegistered(registration.command())) { + 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); + } + } + 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) { + switch (failure) { + case null -> { + // Every cleanup step succeeded: nothing to report. + } + case RuntimeException e -> throw e; + case LinkageError e -> throw e; + default -> throw new IllegalStateException(failure); + } + } + + /** The same bound checker the configuration loader uses, against the actual active slots. */ + private void validateReplyBudget(RuntimeSnapshot active) { + ServerContext context = serverContext; + if (context == null) return; + SupportedFeatures features = supportedFeatures; + HostmaskReplyBudget.requireFits( + net.jircd.protocol.MessageSerializer.utf8Bytes(context.serverName().get()), + features.nicknameMaxLength(), + features.channelNameMaxLength(), + ReplyBudgetCatalog.legacyCompatible().plus(active.replyBudgetCatalog())); + } + + /** Whether {@code stop} failed; the failure is logged, not thrown. */ + private static boolean stopQuietly(Extension extension) { + try { + extension.stop(); + return false; + } catch (RuntimeException | LinkageError e) { + LOG.warn("Extension '{}' failed to stop after a failed start", extension.id(), e); + return true; + } + } + + /** + * 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 + * 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 + * under the lifecycle lock only, never the registry monitor. + */ + public void disable(String id) { + requireNoLifecycleCallback(); + lifecycle.lock(); + try { + disableLocked(id); + } finally { + lifecycle.unlock(); + } + } + + private void disableLocked(String id) { Entry entry = byId.get(id); if (entry == null) { throw new IllegalArgumentException("Unknown extension id: " + id); } - if (entry.state != Extension.State.ENABLED) { + boolean retry = entry.state == Extension.State.FAILED && entry.cleanupPending; + if (entry.state != Extension.State.ENABLED && !retry) { return; } - entry.extension.stop(); - entry.state = Extension.State.DISABLED; + throwIfPresent(cleanUp(entry)); + } + + /** + * 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. + */ + 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); + try { + entry.extension.stop(); + } catch (RuntimeException | LinkageError e) { + // Already withdrawn: it stays withdrawn, recorded as failed rather than half-enabled. + failure = accumulate(failure, e); + } + entry.state = failure == null ? Extension.State.DISABLED : Extension.State.FAILED; + entry.cleanupPending = failure != null; String point = extensionPoint(entry.extension); - if (point != null) { + if (point != null && failure == null) { extensionPointOwners.remove(point, id); } + return failure; } private static String extensionPoint(Extension extension) { @@ -224,5 +564,6 @@ public void updateConfiguredLengths( supportedFeatures = supportedFeatures.withConfiguredLengths( nicknameMaxLength, channelNameMaxLength, topicMaxLength, maxModesPerCommand); + refreshReplyLimits(); } } diff --git a/jircd-core/src/main/java/net/jircd/core/extension/ServerContext.java b/jircd-core/src/main/java/net/jircd/core/extension/ServerContext.java index 5889b1d..a1ff9fd 100644 --- a/jircd-core/src/main/java/net/jircd/core/extension/ServerContext.java +++ b/jircd-core/src/main/java/net/jircd/core/extension/ServerContext.java @@ -17,6 +17,7 @@ import java.util.function.Supplier; import net.jircd.core.config.ConfigurationReloader; +import net.jircd.core.extension.capability.CapabilityServices; import net.jircd.core.session.ChannelRegistry; import net.jircd.core.session.DisconnectCleanup; import net.jircd.core.session.NicknameRegistry; @@ -24,8 +25,9 @@ /** * What an {@link Extension} receives at {@code start(ServerContext)} — its handle onto the core * registries it may need, without depending on the rest of {@code jircd-core}/{@code - * jircd-server}'s wiring directly. {@code serverName} is a live supplier, not a snapshot, since it - * (like the rest of {@code configurationReloader}'s live state) can change across a reload. + * jircd-server}'s wiring directly. {@code capabilityServices} is the capability-specific handle. + * {@code serverName} is a live supplier, not a snapshot, since it (like the rest of {@code + * configurationReloader}'s live state) can change across a reload. */ public record ServerContext( NicknameRegistry nicknameRegistry, @@ -34,4 +36,5 @@ public record ServerContext( Supplier serverName, ConfigurationReloader configurationReloader, DisconnectCleanup disconnectCleanup, - CommandRegistrar commandRegistrar) {} + CommandRegistrar commandRegistrar, + CapabilityServices capabilityServices) {} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/Audience.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/Audience.java new file mode 100644 index 0000000..c4d625d --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/Audience.java @@ -0,0 +1,40 @@ +/* + * 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.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; +import net.jircd.core.session.ClientSession; + +/** + * Core-owned, lazily collected, deduplicated peers of one committed event. The collection runs at + * most once, and only if some listener asks for it. + */ +public final class Audience { + private final Supplier> source; + private List peers; + + public Audience(Supplier> source) { + this.source = Objects.requireNonNull(source); + } + + public synchronized List peers() { + if (peers == null) peers = List.copyOf(new LinkedHashSet<>(source.get())); + return peers; + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/CapabilityDescriptor.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/CapabilityDescriptor.java new file mode 100644 index 0000000..6cd3966 --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/CapabilityDescriptor.java @@ -0,0 +1,39 @@ +/* + * 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.List; +import java.util.Objects; +import java.util.Set; + +/** + * What a capability provider declared at staging: its id, the capability it offers, the providers + * it hard-depends on, and its ordered hooks. Immutable; obtained from {@link + * CapabilityRegistrar#build()}. + */ +public record CapabilityDescriptor( + String providerId, + String capabilityName, + Set requiresProviders, + List registrations) { + + public CapabilityDescriptor { + Tokens.require(providerId, "provider id"); + Tokens.require(capabilityName, "capability name"); + requiresProviders = Set.copyOf(Objects.requireNonNull(requiresProviders)); + registrations = List.copyOf(Objects.requireNonNull(registrations)); + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/CapabilityRegistrar.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/CapabilityRegistrar.java new file mode 100644 index 0000000..168f0da --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/CapabilityRegistrar.java @@ -0,0 +1,243 @@ +/* + * 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.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import net.jircd.core.session.command.CommandHandler; +import net.jircd.protocol.Command; +import net.jircd.protocol.MessageSerializer; + +/** + * Where a capability provider stages its typed hooks. Nothing is visible to the server until the + * descriptor from {@link #build()} is published; closing the registrar (or a {@link Registration}) + * before that discards the staged hooks. A staged registration is bounded and validated here, so a + * provider cannot register something the dispatcher would have to distrust on every call. + * + *

Each hook is gated centrally: the dispatcher applies the owner's enabled state and the + * declared negotiation prerequisites, so providers never read mutable session capability sets. + * Hooks must be pure, nonblocking and thread-safe, and may not perform I/O. + */ +public final class CapabilityRegistrar { + public static final int MAX_REGISTRATIONS = 64; + public static final int MAX_REQUIRED_PROVIDERS = 16; + public static final int MAX_PREREQUISITES = 8; + public static final int MAX_OWNED_KEYS = 16; + + /** The offer core advertises itself; no provider may claim it. */ + static final String CORE_CAP_NOTIFY = "cap-notify"; + + /** Tag owned by the batch shaper, never by an ordinary tag contributor. */ + static final String BATCH_TAG = "batch"; + + private enum State { + OPEN, + BUILT, + CLOSED + } + + private final String providerId; + private final String capabilityName; + private final Set fieldSlots; + private final List staged = new ArrayList<>(); + private final Set requiredProviders = new LinkedHashSet<>(); + private State state = State.OPEN; + + public CapabilityRegistrar(String providerId, String capabilityName) { + this(providerId, capabilityName, Set.of()); + } + + /** {@code fieldSlots} are the extra schema-declared slots {@link #onReplyField} may target. */ + public CapabilityRegistrar(String providerId, String capabilityName, Set fieldSlots) { + this.providerId = Tokens.require(providerId, "provider id"); + this.capabilityName = Tokens.require(capabilityName, "capability name"); + if (CORE_CAP_NOTIFY.equals(capabilityName) || CORE_CAP_NOTIFY.equals(providerId)) { + throw new IllegalArgumentException("'" + CORE_CAP_NOTIFY + "' is a core capability"); + } + this.fieldSlots = Set.copyOf(fieldSlots); + } + + /** Declares hard availability dependencies on other providers; self, duplicates rejected. */ + public synchronized void requiresProviders(String... providerIds) { + requireOpen(); + // Validated as a whole against a copy, so a rejected call stages none of its ids. + Set candidate = new LinkedHashSet<>(requiredProviders); + for (String id : providerIds) { + Tokens.require(id, "required provider id"); + if (id.equals(providerId)) { + throw new IllegalArgumentException("provider '" + id + "' cannot require itself"); + } + if (candidate.contains(id)) { + throw new IllegalArgumentException("duplicate required provider '" + id + "'"); + } + if (candidate.size() >= MAX_REQUIRED_PROVIDERS) { + throw new IllegalStateException( + "more than " + MAX_REQUIRED_PROVIDERS + " required providers"); + } + candidate.add(id); + } + requiredProviders.addAll(candidate); + } + + public Registration onEvent(EventKind kind, Hooks.EventHook hook) { + Objects.requireNonNull(kind); + return stage(builder(HookKind.EVENT, hook).onEvent(kind)); + } + + public Registration onInboundTags(Hooks.InboundTagHook hook) { + return onInboundTags(Set.of(), hook); + } + + /** {@code requiresNegotiated} are sender prerequisites; unmet means the default (ignore). */ + public Registration onInboundTags(Set requiresNegotiated, Hooks.InboundTagHook hook) { + return stage(builder(HookKind.INBOUND_TAGS, hook).requiring(requiresNegotiated)); + } + + public Registration onRecipients(Hooks.RecipientHook hook) { + return stage(builder(HookKind.RECIPIENTS, hook)); + } + + /** + * Registers server tag contributions for recipients that negotiated the owner. {@code ownedKeys} + * are the only keys the hook may return; {@code maxBytes} bounds the contribution's escaped tag + * data bytes. + */ + public Registration onOutboundTags( + Set ownedKeys, long maxBytes, Hooks.OutboundTagHook hook) { + Objects.requireNonNull(hook); + Objects.requireNonNull(ownedKeys); + if (ownedKeys.isEmpty() || ownedKeys.size() > MAX_OWNED_KEYS) { + throw new IllegalArgumentException("owned tag keys must number 1-" + MAX_OWNED_KEYS); + } + for (String key : ownedKeys) { + Tokens.require(key, "tag key"); + if (BATCH_TAG.equals(key)) { + throw new IllegalArgumentException("the 'batch' tag belongs to the batch shaper"); + } + } + if (maxBytes < 1 || maxBytes > MessageSerializer.MAX_TAG_DATA_BYTES_PER_ORIGIN) { + throw new IllegalArgumentException( + "tag bound must be 1-" + MessageSerializer.MAX_TAG_DATA_BYTES_PER_ORIGIN + " bytes"); + } + return stage(builder(HookKind.OUTBOUND_TAGS, hook).owning(Set.copyOf(ownedKeys), maxBytes)); + } + + public Registration onMemberStatus(ReplyBound bound, Hooks.MemberStatusHook hook) { + return stageSlot(HookKind.MEMBER_STATUS, ReplySlot.STATUS, bound, hook); + } + + public Registration onNamesIdentity(ReplyBound bound, Hooks.NamesIdentityHook hook) { + return stageSlot(HookKind.NAMES_IDENTITY, ReplySlot.IDENTITY, bound, hook); + } + + public Registration onReplyField(ReplySlot slot, ReplyBound bound, Hooks.ReplyFieldHook hook) { + Objects.requireNonNull(slot); + if (!fieldSlots.contains(slot)) { + throw new IllegalArgumentException( + "reply slot '" + slot.name() + "' is not declared by the reply schema"); + } + return stageSlot(HookKind.REPLY_FIELD, slot, bound, hook); + } + + /** {@code requiresNegotiated} are session prerequisites; unmet means plain members. */ + public Registration onOutputGroup(Set requiresNegotiated, Hooks.OutputGroupHook hook) { + return stage(builder(HookKind.OUTPUT_GROUP, hook).requiring(requiresNegotiated)); + } + + public Registration command(Command command, CommandHandler handler) { + Objects.requireNonNull(command); + return stage(builder(HookKind.COMMAND, handler).forCommand(command)); + } + + /** Freezes the staged hooks into an immutable descriptor; allowed once. */ + public synchronized CapabilityDescriptor build() { + requireOpen(); + state = State.BUILT; + return new CapabilityDescriptor( + providerId, capabilityName, requiredProviders, List.copyOf(staged)); + } + + /** Discards everything staged and refuses further staging. Idempotent; no-op once built. */ + public synchronized void close() { + if (state == State.OPEN) { + state = State.CLOSED; + staged.clear(); + requiredProviders.clear(); + } + } + + private Registration stageSlot(HookKind kind, ReplySlot slot, ReplyBound bound, Object hook) { + Objects.requireNonNull(bound); + Objects.requireNonNull(hook); + long bytes = new ReplyContribution(providerId, slot, bound).evaluate(ReplyLimits.REFERENCE); + return stage(builder(kind, hook).decorating(slot, bound, bytes)); + } + + private HookRegistration.Builder builder(HookKind kind, Object hook) { + return new HookRegistration.Builder(kind, providerId, capabilityName, hook); + } + + private synchronized Registration stage(HookRegistration.Builder builder) { + requireOpen(); + if (staged.size() >= MAX_REGISTRATIONS) { + throw new IllegalStateException("more than " + MAX_REGISTRATIONS + " registrations"); + } + HookRegistration registration = builder.build(); + if (registration.requiresNegotiated().size() > MAX_PREREQUISITES) { + throw new IllegalArgumentException("more than " + MAX_PREREQUISITES + " prerequisites"); + } + for (String prerequisite : registration.requiresNegotiated()) { + Tokens.require(prerequisite, "prerequisite"); + } + rejectInternalConflict(registration); + staged.add(registration); + return () -> discard(registration); + } + + private void rejectInternalConflict(HookRegistration candidate) { + for (HookRegistration existing : staged) { + if (candidate.slot() != null && candidate.slot().equals(existing.slot())) { + throw new IllegalArgumentException( + "reply slot " + candidate.slot().name() + " is already registered"); + } + if (candidate.kind() == HookKind.OUTPUT_GROUP && existing.kind() == HookKind.OUTPUT_GROUP) { + throw new IllegalArgumentException("only one output group shaper per provider"); + } + if (candidate.command() != null && candidate.command() == existing.command()) { + throw new IllegalArgumentException( + "command " + candidate.command() + " already registered"); + } + for (String key : candidate.ownedKeys()) { + if (existing.ownedKeys().contains(key)) { + throw new IllegalArgumentException("tag key '" + key + "' already registered"); + } + } + } + } + + private synchronized void discard(HookRegistration registration) { + if (state == State.OPEN) staged.removeIf(candidate -> candidate == registration); + } + + private void requireOpen() { + if (state != State.OPEN) { + throw new IllegalStateException("registrar for '" + providerId + "' is no longer staging"); + } + } +} 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 new file mode 100644 index 0000000..70955a6 --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/CapabilityRuntime.java @@ -0,0 +1,60 @@ +/* + * 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.concurrent.atomic.AtomicReference; + +/** + * 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. + */ +public final class CapabilityRuntime { + private final AtomicReference snapshot = + new AtomicReference<>(RuntimeSnapshot.EMPTY); + + public RuntimeSnapshot snapshot() { + return snapshot.get(); + } + + /** Replaces the configured limits slot bounds are evaluated against; providers unchanged. */ + public synchronized void setLimits(ReplyLimits limits) { + snapshot.set(snapshot.get().withLimits(limits)); + } + + /** + * Publishes {@code descriptor}. + * + * @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; + } + + /** + * Withdraws {@code providerId} if published. + * + * @throws IllegalStateException if a published provider still requires it + */ + 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; + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/CapabilityServices.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/CapabilityServices.java new file mode 100644 index 0000000..4cbb338 --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/CapabilityServices.java @@ -0,0 +1,36 @@ +/* + * 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.Objects; +import java.util.function.Supplier; + +/** + * What a capability provider receives through {@code ServerContext}: read access to the current + * published hook table. A provider cannot publish, withdraw or negotiate on anyone's behalf through + * it; commands and hooks are registered at staging only. + */ +public record CapabilityServices(Supplier source) { + + public CapabilityServices { + Objects.requireNonNull(source); + } + + /** The currently published providers and their hooks. */ + public RuntimeSnapshot snapshot() { + return source.get(); + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/CommittedEvent.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/CommittedEvent.java new file mode 100644 index 0000000..2baba3f --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/CommittedEvent.java @@ -0,0 +1,39 @@ +/* + * 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.Objects; + +/** + * An immutable fact about a state transition that has already committed. {@code before}/{@code + * after} are empty strings when the transition has no such value. + */ +public record CommittedEvent( + EventKind kind, + long operationId, + String subject, + String before, + String after, + Audience audience) { + + public CommittedEvent { + Objects.requireNonNull(kind); + Objects.requireNonNull(subject); + Objects.requireNonNull(before); + Objects.requireNonNull(after); + Objects.requireNonNull(audience); + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/DeliveryIntent.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/DeliveryIntent.java new file mode 100644 index 0000000..49795b8 --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/DeliveryIntent.java @@ -0,0 +1,29 @@ +/* + * 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.Objects; +import java.util.Set; +import java.util.UUID; + +/** A core-authorized delivery of one logical envelope, with the roles core already assigned. */ +public record DeliveryIntent(UUID envelopeId, Set roles) { + + public DeliveryIntent { + Objects.requireNonNull(envelopeId); + roles = Set.copyOf(roles); + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/DeliveryRole.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/DeliveryRole.java new file mode 100644 index 0000000..fc0eb3b --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/DeliveryRole.java @@ -0,0 +1,23 @@ +/* + * 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; + +/** Why a recipient receives a logical message. */ +public enum DeliveryRole { + ORDINARY_TARGET, + SENDER_COPY, + REQUEST_REPLY +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/EventIntent.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/EventIntent.java new file mode 100644 index 0000000..c8c9a16 --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/EventIntent.java @@ -0,0 +1,30 @@ +/* + * 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.List; +import java.util.Objects; +import net.jircd.core.session.ClientSession; +import net.jircd.protocol.Message; + +/** A request to deliver one message to peers of an event; core validates and admits it. */ +public record EventIntent(Message message, List recipients) { + + public EventIntent { + Objects.requireNonNull(message); + recipients = List.copyOf(recipients); + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/EventKind.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/EventKind.java new file mode 100644 index 0000000..6040248 --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/EventKind.java @@ -0,0 +1,25 @@ +/* + * 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; + +/** Committed state transitions a capability may observe. */ +public enum EventKind { + AWAY_CHANGED, + JOINED, + PARTED, + QUIT, + NICK_CHANGED +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/FaultCategory.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/FaultCategory.java new file mode 100644 index 0000000..696472f --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/FaultCategory.java @@ -0,0 +1,23 @@ +/* + * 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; + +/** Why a hook result was dropped; deliberately carries no exception text or client content. */ +public enum FaultCategory { + EXCEPTION, + NULL_RESULT, + INVALID_RESULT +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/FaultReporter.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/FaultReporter.java new file mode 100644 index 0000000..7961171 --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/FaultReporter.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.concurrent.TimeUnit; +import net.jircd.core.session.WarningThrottle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Operator-visible report of a dropped hook result. Receives only the provider id, hook kind and a + * category, so nothing client-controlled or secret can reach the log. + */ +@FunctionalInterface +public interface FaultReporter { + + void report(String providerId, HookKind kind, FaultCategory category); + + /** Logs one warning per provider/hook/category per interval, counting the suppressed ones. */ + static FaultReporter logging() { + Logger log = LoggerFactory.getLogger(FaultReporter.class); + WarningThrottle throttle = + new WarningThrottle(TimeUnit.SECONDS.toNanos(10), 256, System::nanoTime); + return (providerId, kind, category) -> { + long suppressed = throttle.admit(providerId + '/' + kind + '/' + category); + if (suppressed >= 0) { + log.warn( + "Capability provider '{}' hook {} result dropped ({}); {} similar suppressed", + providerId, + kind, + category, + suppressed); + } + }; + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/FramingPlan.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/FramingPlan.java new file mode 100644 index 0000000..f6e7ed3 --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/FramingPlan.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; + +import java.util.List; +import net.jircd.protocol.Message; + +/** The complete ordered messages for a group, including any opener and closer. */ +public record FramingPlan(List frames) { + + public FramingPlan { + frames = List.copyOf(frames); + } + + /** The default: the group's members with no framing. */ + public static FramingPlan plain(OutputGroup group) { + return new FramingPlan(group.members()); + } +} 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 new file mode 100644 index 0000000..f0e28fa --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/HookDispatcher.java @@ -0,0 +1,480 @@ +/* + * 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.ArrayList; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +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; +import net.jircd.core.session.ClientSession; +import net.jircd.core.session.FrameSequence; +import net.jircd.core.session.OutboundMessage; +import net.jircd.core.session.command.CommandHandler; +import net.jircd.protocol.Message; +import net.jircd.protocol.MessageSerializer; + +/** + * Runs registered hooks against a captured {@link RuntimeSnapshot}. Every result is untrusted: an + * exception, a {@code null}, or a result outside its declared bounds is dropped whole, reported + * (provider id, hook kind and category only) and replaced by the documented default, so a faulty + * provider can never damage a core outcome or another provider. Ordinary runtime and linkage faults + * are contained; {@link VirtualMachineError}s are not. The dispatcher holds no locks and keeps no + * state, so no hook runs under a domain, admin or queue lock taken here. + * + *

Withdrawing a faulting provider is a lifecycle concern delivered by a later change; until then + * each dropped result is reported and the next call is attempted afresh. + */ +public final class HookDispatcher { + /** Most delivery intents one event hook may return. */ + public static final int MAX_EVENT_INTENTS = 8; + + /** Most opener/closer frames a framing plan may add around the group's members. */ + public static final int MAX_FRAMING_OVERHEAD_FRAMES = 8; + + private static final Set RECIPIENT_HOOK_ROLES = + EnumSet.of(DeliveryRole.SENDER_COPY); + + private final FaultReporter reporter; + + public HookDispatcher(FaultReporter reporter) { + this.reporter = Objects.requireNonNull(reporter); + } + + /** Intents from every hook for the event's kind, narrowed to recipients that negotiated it. */ + public List event(RuntimeSnapshot snapshot, CommittedEvent event) { + List admitted = new ArrayList<>(); + for (HookRegistration registration : snapshot.hooks(HookKind.EVENT)) { + if (registration.eventKind() != event.kind()) continue; + Optional> result = + validated( + registration, + () -> registration.hook(Hooks.EventHook.class).apply(event), + ArrayList::new, + intents -> validEventIntents(intents, event)); + for (EventIntent intent : result.orElse(List.of())) { + List negotiated = + intent.recipients().stream().filter(r -> gated(registration, r)).toList(); + if (!negotiated.isEmpty()) admitted.add(new EventIntent(intent.message(), negotiated)); + } + } + return List.copyOf(admitted); + } + + private static boolean gated(HookRegistration registration, ClientSession recipient) { + Set negotiated = recipient.negotiatedCapabilities(); + return registration.ownerNegotiated(negotiated) && registration.prerequisitesMet(negotiated); + } + + private static boolean validEventIntents(List intents, CommittedEvent event) { + if (intents.size() > MAX_EVENT_INTENTS) return false; + if (intents.isEmpty()) return true; + Set audience = Set.copyOf(event.audience().peers()); + for (EventIntent intent : intents) { + if (intent == null || !audience.containsAll(intent.recipients())) return false; + } + return true; + } + + /** Merged inbound-tag decisions from hooks whose sender prerequisites are met. */ + public InboundTagResult inboundTags( + RuntimeSnapshot snapshot, + Set senderNegotiated, + InboundTags input, + RequestContext context) { + Map forwarded = new LinkedHashMap<>(); + Map attributes = new LinkedHashMap<>(); + for (HookRegistration registration : snapshot.hooks(HookKind.INBOUND_TAGS)) { + if (!registration.prerequisitesMet(senderNegotiated)) continue; + Optional result = + validated( + registration, + () -> registration.hook(Hooks.InboundTagHook.class).apply(input, context), + r -> validInbound(r, registration, input)); + result.ifPresent( + r -> { + forwarded.putAll(r.forwarded()); + attributes.putAll(r.context()); + }); + } + return forwarded.isEmpty() && attributes.isEmpty() + ? InboundTagResult.NONE + : new InboundTagResult(forwarded, attributes); + } + + private static boolean validInbound( + InboundTagResult result, HookRegistration registration, InboundTags input) { + for (var entry : result.forwarded().entrySet()) { + if (!entry.getKey().startsWith("+") + || !entry.getValue().equals(input.tags().get(entry.getKey()))) { + return false; + } + } + String namespace = registration.providerId() + "/"; + for (String key : result.context().keySet()) { + if (!key.startsWith(namespace)) return false; + } + return RequestContext.validAttributes(result.context()); + } + + /** Additional delivery roles from hooks whose owner the sender negotiated. */ + public Set recipientRoles( + RuntimeSnapshot snapshot, + Set senderNegotiated, + DeliveryIntent intent, + RequestContext context) { + Set roles = EnumSet.noneOf(DeliveryRole.class); + for (HookRegistration registration : snapshot.hooksFor(HookKind.RECIPIENTS, senderNegotiated)) { + validated( + registration, + () -> registration.hook(Hooks.RecipientHook.class).apply(intent, context), + HashSet::new, + RECIPIENT_HOOK_ROLES::containsAll) + .ifPresent(roles::addAll); + } + return Set.copyOf(roles); + } + + /** + * One tag hook's contribution, measured with the serializer's escaping against its declared bound + * and limited to its owned keys. Empty on any fault. Callers memoize per envelope and hook. + */ + public Map outboundTags(HookRegistration registration, OutboundMessage message) { + return validated( + registration, + () -> registration.hook(Hooks.OutboundTagHook.class).apply(message), + LinkedHashMap::new, + tags -> validTags(tags, registration)) + .map(Map::copyOf) + .orElse(Map.of()); + } + + /** + * The temporary legacy path: a not yet migrated provider's tags, contained like a hook. A throw, + * a {@code null} or more than one origin may carry is dropped whole and reported, so the core + * message still goes out. + */ + public Map legacyOutboundTags( + String providerId, Supplier> call) { + Map copy; + try { + Map raw = call.get(); + if (raw == null) { + reporter.report(providerId, HookKind.OUTBOUND_TAGS, FaultCategory.NULL_RESULT); + return Map.of(); + } + copy = Map.copyOf(raw); + } catch (RuntimeException | LinkageError _) { + reporter.report(providerId, HookKind.OUTBOUND_TAGS, FaultCategory.EXCEPTION); + return Map.of(); + } + if (!MessageSerializer.tagDataBytesAtMost( + copy, MessageSerializer.MAX_TAG_DATA_BYTES_PER_ORIGIN)) { + reporter.report(providerId, HookKind.OUTBOUND_TAGS, FaultCategory.INVALID_RESULT); + return Map.of(); + } + return copy; + } + + private static boolean validTags(Map tags, HookRegistration registration) { + for (var entry : tags.entrySet()) { + String value = entry.getValue(); + if (!registration.ownedKeys().contains(entry.getKey()) + || value == null + || value.indexOf('\0') >= 0) { + return false; + } + } + return MessageSerializer.tagDataBytesAtMost(tags, registration.maxBytes()); + } + + /** The requester's status string: the owner's replacement, else core's highest status. */ + public String memberStatus( + RuntimeSnapshot snapshot, Set requesterNegotiated, MemberSnapshot member) { + for (HookRegistration registration : + snapshot.hooksFor(HookKind.MEMBER_STATUS, requesterNegotiated)) { + long bound = slotBytes(snapshot, registration); + Optional status = + bound < 0 + ? Optional.empty() + : validated( + registration, + () -> registration.hook(Hooks.MemberStatusHook.class).apply(member), + s -> validStatus(s, member, bound)); + if (status.isPresent()) return status.get(); + } + return member.highestStatus(); + } + + /** + * The slot bound evaluated against the snapshot's configured limits, the same values startup and + * enable reserved. An invalid declaration is a provider fault: -1 and the hook is skipped. + */ + private long slotBytes(RuntimeSnapshot snapshot, HookRegistration registration) { + try { + long bytes = registration.replyBound().maxBytes(snapshot.limits()); + if (bytes >= 0 && bytes <= MessageSerializer.MAX_COMMAND_BYTES) return bytes; + } catch (RuntimeException | LinkageError _) { + // Reported below as an invalid result. + } + fault(registration, FaultCategory.INVALID_RESULT); + return -1; + } + + private static boolean validStatus(String status, MemberSnapshot member, long bound) { + if (MessageSerializer.utf8Bytes(status) > bound) return false; + int next = 0; + for (int codePoint : status.codePoints().toArray()) { + String symbol = new String(Character.toChars(codePoint)); + while (next < member.heldStatuses().size() + && !member.heldStatuses().get(next).equals(symbol)) { + next++; + } + if (next == member.heldStatuses().size()) return false; + next++; + } + return true; + } + + /** The NAMES identity token: the owner's replacement, else the nickname. */ + public String namesIdentity( + RuntimeSnapshot snapshot, Set requesterNegotiated, MemberSnapshot member) { + for (HookRegistration registration : + snapshot.hooksFor(HookKind.NAMES_IDENTITY, requesterNegotiated)) { + long bound = slotBytes(snapshot, registration); + Optional identity = + bound < 0 + ? Optional.empty() + : validated( + registration, + () -> registration.hook(Hooks.NamesIdentityHook.class).apply(member), + token -> validIdentity(token, member, bound)); + if (identity.isPresent()) return identity.get(); + } + return member.nickname(); + } + + private static boolean validIdentity(String token, MemberSnapshot member, long bound) { + if (token.isEmpty()) return false; + for (int i = 0; i < token.length(); i++) { + char c = token.charAt(i); + if (c <= ' ' || c == 0x7f) return false; + } + return MessageSerializer.utf8Bytes(token) + <= MessageSerializer.utf8Bytes(member.nickname()) + bound; + } + + /** A schema-declared slot's bounded text, or empty for the schema default. */ + public Optional replyField( + RuntimeSnapshot snapshot, + Set requesterNegotiated, + ReplySlot slot, + MemberSnapshot member) { + for (HookRegistration registration : + snapshot.hooksFor(HookKind.REPLY_FIELD, requesterNegotiated)) { + long bound = slot.equals(registration.slot()) ? slotBytes(snapshot, registration) : -1; + Optional field = + bound < 0 + ? Optional.empty() + : validated( + registration, + () -> registration.hook(Hooks.ReplyFieldHook.class).apply(member), + text -> validField(text, bound)); + if (field.isPresent()) return field; + } + return Optional.empty(); + } + + private static boolean validField(String text, long bound) { + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '\r' || c == '\n' || c == 0) return false; + } + return MessageSerializer.utf8Bytes(text) <= bound; + } + + /** The group's framing for this recipient: the owner's valid plan, else plain members. */ + public FramingPlan outputGroup( + RuntimeSnapshot snapshot, + Set recipientNegotiated, + OutputGroup group, + RequestContext context) { + for (HookRegistration registration : + snapshot.hooksFor(HookKind.OUTPUT_GROUP, recipientNegotiated)) { + Optional plan = + validated( + registration, + () -> registration.hook(Hooks.OutputGroupHook.class).apply(group, context), + p -> validPlan(p, group)); + if (plan.isPresent()) return plan.get(); + } + return FramingPlan.plain(group); + } + + /** + * A plan is valid when every member appears exactly once and in order (a frame may add tags to + * its member), the other frames are openers before the first member and the same number of + * closers after the last, each closer pairs with its opener from the inside out (same command, + * opener parameter {@code +ref}, closer {@code -ref}), and the exact bytes fit the group's bound. + */ + private static boolean validPlan(FramingPlan plan, OutputGroup group) { + List members = group.members(); + List frames = plan.frames(); + if (frames.isEmpty() || frames.size() > members.size() + MAX_FRAMING_OVERHEAD_FRAMES) { + return false; + } + List openers = new ArrayList<>(); + List closers = new ArrayList<>(); + if (!split(frames, members, openers, closers)) return false; + if (openers.size() != closers.size()) return false; + for (int i = 0; i < openers.size(); i++) { + if (!paired(openers.get(i), closers.get(closers.size() - 1 - i))) return false; + } + return FrameSequence.of(frames).bytes() <= group.byteBound(); + } + + /** Splits {@code frames} into openers, the members in order, then closers; false if it cannot. */ + private static boolean split( + List frames, List members, List openers, List closers) { + int matched = 0; + for (Message frame : frames) { + if (matched < members.size() && isMember(frame, members.get(matched))) { + matched++; + } else if (matched == 0 && !members.isEmpty()) { + openers.add(frame); + } else if (matched == members.size() && !members.isEmpty()) { + closers.add(frame); + } else if (members.isEmpty()) { + openers.add(frame); + } else { + return false; // framing between members + } + } + if (members.isEmpty()) { + int half = openers.size() / 2; + closers.addAll(openers.subList(half, openers.size())); + openers.subList(half, openers.size()).clear(); + } + return matched == members.size(); + } + + private static boolean paired(Message opener, Message closer) { + if (!Objects.equals(opener.rawCommand(), closer.rawCommand())) return false; + if (opener.params().isEmpty() || closer.params().isEmpty()) return false; + String open = opener.params().getFirst(); + String close = closer.params().getFirst(); + return open.length() > 1 + && open.startsWith("+") + && close.startsWith("-") + && open.substring(1).equals(close.substring(1)); + } + + private static boolean isMember(Message frame, Message member) { + return Objects.equals(frame.rawCommand(), member.rawCommand()) + && Objects.equals(frame.prefix(), member.prefix()) + && frame.params().equals(member.params()) + && frame.tags().entrySet().containsAll(member.tags().entrySet()); + } + + /** Reports a tag hook whose contribution was dropped because the aggregate would overflow. */ + public void reportAggregateOverflow(HookRegistration registration) { + fault(registration, FaultCategory.INVALID_RESULT); + } + + /** The same for a legacy provider, which has no registration. */ + public void reportAggregateOverflow(String providerId) { + reporter.report(providerId, HookKind.OUTBOUND_TAGS, FaultCategory.INVALID_RESULT); + } + + /** 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 _) { + fault(registration, FaultCategory.EXCEPTION); + } + }; + } + + /** For results that are already immutable values. */ + private Optional validated( + HookRegistration registration, Supplier call, Predicate valid) { + return validated(registration, call, UnaryOperator.identity(), valid); + } + + /** + * Runs the hook, snapshots its result with {@code copier} and validates and returns only that + * copy, so a container the provider keeps changing cannot differ from what was checked. A failure + * while copying is a hook fault like any other. + */ + private Optional validated( + HookRegistration registration, + Supplier call, + UnaryOperator copier, + Predicate valid) { + T result; + try { + T raw = call.get(); + result = raw == null ? null : copier.apply(raw); + } catch (RuntimeException | LinkageError _) { + fault(registration, FaultCategory.EXCEPTION); + return Optional.empty(); + } + if (result == null) { + fault(registration, FaultCategory.NULL_RESULT); + return Optional.empty(); + } + boolean ok; + try { + ok = valid.test(result); + } catch (RuntimeException | LinkageError _) { + ok = false; + } + if (!ok) { + fault(registration, FaultCategory.INVALID_RESULT); + return Optional.empty(); + } + return Optional.of(result); + } + + private void fault(HookRegistration registration, FaultCategory category) { + reporter.report(registration.providerId(), registration.kind(), category); + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/HookKind.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/HookKind.java new file mode 100644 index 0000000..338170f --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/HookKind.java @@ -0,0 +1,31 @@ +/* + * 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; + +/** + * The typed registration points a capability provider can use (see {@link CapabilityRegistrar}). + */ +public enum HookKind { + EVENT, + INBOUND_TAGS, + RECIPIENTS, + OUTBOUND_TAGS, + MEMBER_STATUS, + NAMES_IDENTITY, + REPLY_FIELD, + OUTPUT_GROUP, + COMMAND +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/HookRegistration.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/HookRegistration.java new file mode 100644 index 0000000..23ee16e --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/HookRegistration.java @@ -0,0 +1,169 @@ +/* + * 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.Objects; +import java.util.Set; +import net.jircd.core.session.command.CommandHandler; +import net.jircd.protocol.Command; + +/** + * One typed, owned hook captured at staging. Immutable; compared by identity, so it doubles as the + * memoization key for per-envelope results (never as a lifecycle version). + */ +public final class HookRegistration { + private final HookKind kind; + private final String providerId; + private final String capabilityName; + private final Object hook; + private final Set requiresNegotiated; + private final Set ownedKeys; + private final ReplySlot slot; + private final ReplyBound replyBound; + private final EventKind eventKind; + private final Command command; + private final long maxBytes; + + private HookRegistration(Builder builder) { + this.kind = Objects.requireNonNull(builder.kind); + this.providerId = builder.providerId; + this.capabilityName = builder.capabilityName; + this.hook = Objects.requireNonNull(builder.hook); + this.requiresNegotiated = Set.copyOf(builder.requiresNegotiated); + this.ownedKeys = Set.copyOf(builder.ownedKeys); + this.slot = builder.slot; + this.replyBound = builder.replyBound; + this.eventKind = builder.eventKind; + this.command = builder.command; + this.maxBytes = builder.maxBytes; + } + + /** Collects a registration's attributes at staging; only the registrar creates one. */ + static final class Builder { + private final HookKind kind; + private final String providerId; + private final String capabilityName; + private final Object hook; + private Set requiresNegotiated = Set.of(); + private Set ownedKeys = Set.of(); + private ReplySlot slot; + private ReplyBound replyBound; + private EventKind eventKind; + private Command command; + private long maxBytes; + + Builder(HookKind kind, String providerId, String capabilityName, Object hook) { + this.kind = kind; + this.providerId = providerId; + this.capabilityName = capabilityName; + this.hook = Objects.requireNonNull(hook); + } + + Builder requiring(Set negotiated) { + this.requiresNegotiated = Objects.requireNonNull(negotiated); + return this; + } + + Builder owning(Set keys, long bytes) { + this.ownedKeys = keys; + this.maxBytes = bytes; + return this; + } + + Builder decorating(ReplySlot replySlot, ReplyBound bound, long bytes) { + this.slot = replySlot; + this.replyBound = bound; + this.maxBytes = bytes; + return this; + } + + Builder onEvent(EventKind event) { + this.eventKind = event; + return this; + } + + Builder forCommand(Command handled) { + this.command = handled; + return this; + } + + HookRegistration build() { + return new HookRegistration(this); + } + } + + public HookKind kind() { + return kind; + } + + public String providerId() { + return providerId; + } + + /** The capability the owner offers; recipients/senders must have negotiated it where gated. */ + public String capabilityName() { + return capabilityName; + } + + /** Further session capabilities that must be negotiated, evaluated with the owner's. */ + public Set requiresNegotiated() { + return requiresNegotiated; + } + + /** Server tag keys this registration owns; empty for other kinds. */ + public Set ownedKeys() { + return ownedKeys; + } + + public ReplySlot slot() { + return slot; + } + + public ReplyBound replyBound() { + return replyBound; + } + + public EventKind eventKind() { + return eventKind; + } + + public Command command() { + return command; + } + + /** The declared byte bound: tag data bytes, or the reply slot's bytes at reference limits. */ + public long maxBytes() { + return maxBytes; + } + + T hook(Class type) { + return type.cast(hook); + } + + CommandHandler commandHandler() { + return hook(CommandHandler.class); + } + + /** Whether {@code negotiated} satisfies this registration's declared session prerequisites. */ + boolean prerequisitesMet(Set negotiated) { + return negotiated.containsAll(requiresNegotiated); + } + + /** Whether the party also negotiated the owner's capability. */ + boolean ownerNegotiated(Set negotiated) { + return negotiated.contains(capabilityName); + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/Hooks.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/Hooks.java new file mode 100644 index 0000000..c7a5f73 --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/Hooks.java @@ -0,0 +1,73 @@ +/* + * 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.List; +import java.util.Map; +import java.util.Set; +import net.jircd.core.session.OutboundMessage; + +/** The functional shapes of the typed hooks {@link CapabilityRegistrar} accepts. */ +public interface Hooks { + + /** Reacts to a committed event with delivery intents for permitted peers. */ + @FunctionalInterface + interface EventHook { + List apply(CommittedEvent event); + } + + /** Selects forwarded client tags and provider-scoped request context. */ + @FunctionalInterface + interface InboundTagHook { + InboundTagResult apply(InboundTags tags, RequestContext context); + } + + /** Adds delivery roles (sender copy) for an authorized delivery. */ + @FunctionalInterface + interface RecipientHook { + Set apply(DeliveryIntent intent, RequestContext context); + } + + /** Contributes owned server tags; pure in the immutable message. */ + @FunctionalInterface + interface OutboundTagHook { + Map apply(OutboundMessage message); + } + + /** Replaces a member's status string with held symbols in PREFIX order. */ + @FunctionalInterface + interface MemberStatusHook { + String apply(MemberSnapshot member); + } + + /** Replaces a NAMES member's identity token. */ + @FunctionalInterface + interface NamesIdentityHook { + String apply(MemberSnapshot member); + } + + /** Contributes bounded text to a schema-declared reply slot. */ + @FunctionalInterface + interface ReplyFieldHook { + String apply(MemberSnapshot member); + } + + /** Frames a finite output group. */ + @FunctionalInterface + interface OutputGroupHook { + FramingPlan apply(OutputGroup group, RequestContext context); + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/InboundTagResult.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/InboundTagResult.java new file mode 100644 index 0000000..0a5ce97 --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/InboundTagResult.java @@ -0,0 +1,31 @@ +/* + * 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.Map; + +/** + * A provider's decision about inbound tags: which client-only ({@code +}) tags to forward, and + * provider-scoped context. The default ignores semantic tags. + */ +public record InboundTagResult(Map forwarded, Map context) { + public static final InboundTagResult NONE = new InboundTagResult(Map.of(), Map.of()); + + public InboundTagResult { + forwarded = Map.copyOf(forwarded); + context = Map.copyOf(context); + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/InboundTags.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/InboundTags.java new file mode 100644 index 0000000..d7dcbe8 --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/InboundTags.java @@ -0,0 +1,28 @@ +/* + * 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.Map; +import java.util.Objects; + +/** The parsed, size-validated client tags of one inbound command. */ +public record InboundTags(String command, Map tags) { + + public InboundTags { + Objects.requireNonNull(command); + tags = Map.copyOf(tags); + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/MemberSnapshot.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/MemberSnapshot.java new file mode 100644 index 0000000..c2fbe68 --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/MemberSnapshot.java @@ -0,0 +1,39 @@ +/* + * 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.List; +import java.util.Objects; + +/** + * Identity permitted for the requester plus every held channel status symbol in PREFIX order. It + * holds no capability flag: providers choose how to render it. + */ +public record MemberSnapshot( + String nickname, String ident, String displayHost, List heldStatuses) { + + public MemberSnapshot { + Objects.requireNonNull(nickname); + Objects.requireNonNull(ident); + Objects.requireNonNull(displayHost); + heldStatuses = List.copyOf(heldStatuses); + } + + /** Core's default status: the highest held symbol, or none. */ + public String highestStatus() { + return heldStatuses.isEmpty() ? "" : heldStatuses.getFirst(); + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/OutputGroup.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/OutputGroup.java new file mode 100644 index 0000000..b889124 --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/OutputGroup.java @@ -0,0 +1,33 @@ +/* + * 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.List; +import java.util.Objects; +import net.jircd.protocol.Message; + +/** A finite logical group of messages with a conservative byte bound for the recipient. */ +public record OutputGroup( + String id, String type, List typeParams, List members, long byteBound) { + + public OutputGroup { + Objects.requireNonNull(id); + Objects.requireNonNull(type); + typeParams = List.copyOf(typeParams); + members = List.copyOf(members); + if (byteBound < 0) throw new IllegalArgumentException("byteBound must not be negative"); + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/Registration.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/Registration.java new file mode 100644 index 0000000..f5dcde3 --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/Registration.java @@ -0,0 +1,24 @@ +/* + * 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 staged hook owned by its provider; closing it before publication removes it. */ +public interface Registration extends AutoCloseable { + + /** Idempotent. */ + @Override + void close(); +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/ReplyBound.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/ReplyBound.java new file mode 100644 index 0000000..cdc1bb8 --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/ReplyBound.java @@ -0,0 +1,26 @@ +/* + * 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 provider's declared worst-case bytes for one slot, as a pure function of validated limits. Must + * return a finite value between 0 and the 512-byte command limit. + */ +@FunctionalInterface +public interface ReplyBound { + + long maxBytes(ReplyLimits limits); +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/ReplyBudgetCatalog.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/ReplyBudgetCatalog.java new file mode 100644 index 0000000..13687ce --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/ReplyBudgetCatalog.java @@ -0,0 +1,86 @@ +/* + * 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.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * The reply decorations a set of providers could apply. Mutually exclusive replacement slots + * reserve the largest declared bound across providers (any one of them may own the slot), and + * different slots compose by summing in each layout's formula. The same checker serves startup, + * reload and runtime enable. + */ +public record ReplyBudgetCatalog(List contributions) { + + private static final ReplyBudgetCatalog EMPTY = new ReplyBudgetCatalog(List.of()); + + /** + * Widths the server reserved before providers declared them: a two-symbol status (@ and +), so + * configurations accepted today stay accepted until the multi-prefix provider declares its own. + */ + private static final ReplyBudgetCatalog LEGACY = + new ReplyBudgetCatalog(List.of(new ReplyContribution("legacy", ReplySlot.STATUS, l -> 2))); + + public ReplyBudgetCatalog { + contributions = List.copyOf(contributions); + } + + public static ReplyBudgetCatalog empty() { + return EMPTY; + } + + public static ReplyBudgetCatalog legacyCompatible() { + return LEGACY; + } + + /** The slot decorations declared by {@code descriptors}' hooks. */ + public static ReplyBudgetCatalog fromDescriptors(Collection descriptors) { + List contributions = new ArrayList<>(); + for (CapabilityDescriptor descriptor : descriptors) { + for (HookRegistration r : descriptor.registrations()) { + if (r.slot() != null) { + contributions.add(new ReplyContribution(r.providerId(), r.slot(), r.replyBound())); + } + } + } + return new ReplyBudgetCatalog(contributions); + } + + /** This catalog plus {@code other}'s contributions. */ + public ReplyBudgetCatalog plus(ReplyBudgetCatalog other) { + List merged = new ArrayList<>(contributions); + merged.addAll(other.contributions); + return new ReplyBudgetCatalog(merged); + } + + /** Reserved bytes per slot, evaluated against {@code limits}. */ + public Map slotBytes(ReplyLimits limits) { + Map bytes = new HashMap<>(); + for (ReplyContribution contribution : contributions) { + bytes.merge(contribution.slot(), contribution.evaluate(limits), Math::max); + } + return bytes; + } + + /** Worst-case bytes of one reply under this catalog. */ + public long worstCaseBytes(ReplyLayout layout, ReplyLimits limits) { + return layout.worstCaseBytes(limits, slotBytes(limits)); + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/ReplyContribution.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/ReplyContribution.java new file mode 100644 index 0000000..80ac32e --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/ReplyContribution.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.Objects; + +/** One provider's declared decoration of one reply slot. */ +public record ReplyContribution(String providerId, ReplySlot slot, ReplyBound bound) { + + public ReplyContribution { + Objects.requireNonNull(providerId); + Objects.requireNonNull(slot); + Objects.requireNonNull(bound); + } + + /** The bound evaluated against {@code limits}, rejecting anything outside 0..512. */ + long evaluate(ReplyLimits limits) { + long bytes; + try { + bytes = bound.maxBytes(limits); + } catch (RuntimeException | LinkageError e) { + throw new IllegalArgumentException( + "Provider '" + providerId + "' reply bound for slot " + slot.name() + " failed", e); + } + if (bytes < 0 || bytes > net.jircd.protocol.MessageSerializer.MAX_COMMAND_BYTES) { + throw new IllegalArgumentException( + "Provider '" + + providerId + + "' reply bound for slot " + + slot.name() + + " must be 0..512 bytes, got " + + bytes); + } + return bytes; + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/ReplyLayout.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/ReplyLayout.java new file mode 100644 index 0000000..3466ad8 --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/ReplyLayout.java @@ -0,0 +1,98 @@ +/* + * 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.Map; +import java.util.Set; + +/** + * The schema of one core reply: its fixed bytes, the slots providers may decorate and the formula + * for its worst-case size including CRLF. Core owns the fixed fields and fit policy. + * + *

With {@code S} the server name bytes, {@code N}/{@code C} the nickname and channel limits, + * {@code U}/{@code H} the ident and presented-host maxima, {@code P} the status-slot bytes (at + * least the core highest-status byte) and {@code I} the identity bytes beyond the nickname. + */ +public final class ReplyLayout { + + /** Core's highest-status default is at most one byte. */ + static final long CORE_STATUS_BYTES = 1; + + /** One 353 member token: {@code S + 2N + C + 13 + P + I}. */ + public static final ReplyLayout NAMES_MEMBER = + new ReplyLayout( + "353 (NAMES)", + Set.of(ReplySlot.STATUS, ReplySlot.IDENTITY), + (l, slots) -> + Math.addExact( + Math.addExact( + l.serverBytes() + 2L * l.nicknameMax() + l.channelMax() + 13, status(slots)), + slots.getOrDefault(ReplySlot.IDENTITY, 0L))); + + /** The closing 366: {@code S + N + C + 29}. */ + public static final ReplyLayout NAMES_END = + new ReplyLayout( + "366 (ENDOFNAMES)", + Set.of(), + (l, slots) -> (long) l.serverBytes() + l.nicknameMax() + l.channelMax() + 29); + + /** A channel 352: {@code 2S + 2N + C + U + H + 18 + F} where {@code F = 2 + P}. */ + public static final ReplyLayout WHO_ENTRY = + new ReplyLayout( + "352 (WHO)", + Set.of(ReplySlot.STATUS), + (l, slots) -> + 2L * l.serverBytes() + + 2L * l.nicknameMax() + + l.channelMax() + + l.identMax() + + l.hostMax() + + 18 + + 2 + + status(slots)); + + @FunctionalInterface + private interface Formula { + long bytes(ReplyLimits limits, Map slotBytes); + } + + private final String name; + private final Set slots; + private final Formula formula; + + private ReplyLayout(String name, Set slots, Formula formula) { + this.name = name; + this.slots = slots; + this.formula = formula; + } + + private static long status(Map slots) { + return Math.max(CORE_STATUS_BYTES, slots.getOrDefault(ReplySlot.STATUS, 0L)); + } + + public String name() { + return name; + } + + public Set slots() { + return slots; + } + + /** Worst-case bytes including CRLF given each slot's reserved bytes. */ + public long worstCaseBytes(ReplyLimits limits, Map slotBytes) { + return formula.bytes(limits, slotBytes); + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/ReplyLimits.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/ReplyLimits.java new file mode 100644 index 0000000..74c5712 --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/ReplyLimits.java @@ -0,0 +1,44 @@ +/* + * 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 net.jircd.core.config.ServerConfiguration; + +/** + * The validated configured limits a reply bound is evaluated from: server name, nickname and + * channel-name byte limits plus the fixed ident and presented-host maxima. + */ +public record ReplyLimits( + int serverBytes, int nicknameMax, int channelMax, int identMax, int hostMax) { + + /** Representative limits used only to sanity-check a declared bound at registration. */ + public static final ReplyLimits REFERENCE = of(253, 30, 50); + + public ReplyLimits { + if (serverBytes < 0 || nicknameMax < 0 || channelMax < 0 || identMax < 0 || hostMax < 0) { + throw new IllegalArgumentException("reply limits must not be negative"); + } + } + + public static ReplyLimits of(int serverBytes, int nicknameMax, int channelMax) { + return new ReplyLimits( + serverBytes, + nicknameMax, + channelMax, + ServerConfiguration.IDENT_MAX_BYTES, + ServerConfiguration.PRESENTED_HOST_MAX_BYTES); + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/ReplySlot.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/ReplySlot.java new file mode 100644 index 0000000..7c3fd8b --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/ReplySlot.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 place in a core reply schema that a provider may decorate. Only slots a {@link ReplyLayout} + * declares can be registered; a provider cannot add parameters or change a numeric's arity. + */ +public record ReplySlot(String name) { + /** The channel status symbols in a NAMES member token and a channel WHO flags field. */ + public static final ReplySlot STATUS = new ReplySlot("STATUS"); + + /** The NAMES member identity token ({@code nick} by default). */ + public static final ReplySlot IDENTITY = new ReplySlot("IDENTITY"); + + public ReplySlot { + Tokens.require(name, "reply slot name"); + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/RequestContext.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/RequestContext.java new file mode 100644 index 0000000..c607980 --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/RequestContext.java @@ -0,0 +1,52 @@ +/* + * 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.Map; + +/** + * Immutable context of one inbound request: a core request id plus bounded, provider-namespaced + * attributes. Core never interprets the attributes. + */ +public record RequestContext(long requestId, Map attributes) { + public static final int MAX_ATTRIBUTES = 8; + public static final int MAX_ATTRIBUTE_BYTES = 256; + + public RequestContext { + attributes = Map.copyOf(attributes); + if (!validAttributes(attributes)) { + throw new IllegalArgumentException("request attributes exceed their bounds"); + } + } + + public static RequestContext of(long requestId, Map attributes) { + return new RequestContext(requestId, attributes); + } + + /** Whether {@code attributes} is a bounded set of {@code provider/key} entries. */ + static boolean validAttributes(Map attributes) { + if (attributes.size() > MAX_ATTRIBUTES) return false; + for (var entry : attributes.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + if (key == null || value == null || key.indexOf('/') <= 0 || !Tokens.isSafe(key)) { + return false; + } + if (net.jircd.protocol.MessageSerializer.utf8Bytes(value) > MAX_ATTRIBUTE_BYTES) return false; + } + return true; + } +} 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 new file mode 100644 index 0000000..d9aa0c8 --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/RuntimeSnapshot.java @@ -0,0 +1,194 @@ +/* + * 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.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; + +/** + * An immutable, coherent table of published providers and their hook indexes in deterministic + * (provider id, then registration) order. Build a new snapshot to change anything; a captured one + * stays valid for as long as the work using it runs. + */ +public final class RuntimeSnapshot { + + public static final RuntimeSnapshot EMPTY = new RuntimeSnapshot(Map.of(), ReplyLimits.REFERENCE); + + private final Map providers; + private final Map> hooks; + private final ReplyLimits limits; + + private RuntimeSnapshot(Map providers, ReplyLimits limits) { + this.limits = limits; + this.providers = Map.copyOf(providers); + Map> index = new EnumMap<>(HookKind.class); + for (HookKind kind : HookKind.values()) index.put(kind, new ArrayList<>()); + providers.values().stream() + .sorted(Comparator.comparing(CapabilityDescriptor::providerId)) + .forEach(d -> d.registrations().forEach(r -> index.get(r.kind()).add(r))); + index.replaceAll((kind, list) -> List.copyOf(list)); + this.hooks = index; + } + + /** + * Validates uniqueness of ids, offers, tag keys, replacement slots and commands, and that every + * hard dependency exists and the dependency graph is acyclic. + * + * @throws IllegalArgumentException if the set is inconsistent; nothing is built + */ + public static RuntimeSnapshot of(Collection descriptors) { + Map byId = new TreeMap<>(); + Set offers = new HashSet<>(); + Map owners = new HashMap<>(); + for (CapabilityDescriptor descriptor : descriptors) { + if (byId.putIfAbsent(descriptor.providerId(), descriptor) != null) { + throw new IllegalArgumentException( + "duplicate provider id '" + descriptor.providerId() + "'"); + } + if (!offers.add(descriptor.capabilityName())) { + throw new IllegalArgumentException( + "capability '" + + descriptor.capabilityName() + + "' is offered by more than one provider"); + } + for (HookRegistration registration : descriptor.registrations()) { + claim(owners, registration, descriptor.providerId()); + } + } + validateDependencies(byId); + return new RuntimeSnapshot(byId, ReplyLimits.REFERENCE); + } + + private static void claim(Map owners, HookRegistration r, String providerId) { + for (String key : r.ownedKeys()) claimOne(owners, "tag key '" + key + "'", providerId); + if (r.slot() != null) claimOne(owners, "reply slot " + r.slot().name(), providerId); + if (r.command() != null) claimOne(owners, "command " + r.command(), providerId); + } + + private static void claimOne(Map owners, String what, String providerId) { + String existing = owners.putIfAbsent(what, providerId); + if (existing != null) { + throw new IllegalArgumentException( + what + " is claimed by both '" + existing + "' and '" + providerId + "'"); + } + } + + private static void validateDependencies(Map byId) { + for (CapabilityDescriptor descriptor : byId.values()) { + for (String required : descriptor.requiresProviders()) { + if (!byId.containsKey(required)) { + throw new IllegalArgumentException( + "provider '" + + descriptor.providerId() + + "' requires unknown provider '" + + required + + "'"); + } + } + } + Set done = new HashSet<>(); + for (String id : byId.keySet()) visit(id, byId, new HashSet<>(), done); + } + + private static void visit( + String id, Map byId, Set path, Set done) { + if (done.contains(id)) return; + if (!path.add(id)) { + throw new IllegalArgumentException("cyclic provider dependency through '" + id + "'"); + } + for (String required : byId.get(id).requiresProviders()) visit(required, byId, path, done); + path.remove(id); + done.add(id); + } + + /** A snapshot with {@code descriptor} added. */ + public RuntimeSnapshot with(CapabilityDescriptor descriptor) { + List all = new ArrayList<>(providers.values()); + all.add(descriptor); + return of(all).withLimits(limits); + } + + /** + * A snapshot without {@code providerId}. + * + * @throws IllegalStateException if another published provider still requires it + */ + 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() + "'"); + } + } + Map remaining = new HashMap<>(providers); + remaining.remove(providerId); + return of(remaining.values()).withLimits(limits); + } + + /** The validated configured limits slot bounds are evaluated against at dispatch. */ + public ReplyLimits limits() { + return limits; + } + + /** The same providers under {@code configured} limits. */ + public RuntimeSnapshot withLimits(ReplyLimits configured) { + return new RuntimeSnapshot(providers, Objects.requireNonNull(configured)); + } + + public Optional descriptor(String providerId) { + return Optional.ofNullable(providers.get(providerId)); + } + + /** Whether {@code providerId} published at least one hook, i.e. has migrated off legacy hooks. */ + public boolean hasHooks(String providerId) { + CapabilityDescriptor descriptor = providers.get(providerId); + return descriptor != null && !descriptor.registrations().isEmpty(); + } + + public Set providerIds() { + return providers.keySet(); + } + + /** Hooks of {@code kind} in provider-id, then registration, order. */ + public List hooks(HookKind kind) { + return hooks.get(kind); + } + + /** + * Hooks of {@code kind} whose owner and declared prerequisites {@code negotiated} satisfies, in + * the same deterministic order. Reads only this snapshot's index, never the live registry. + */ + public List hooksFor(HookKind kind, java.util.Set negotiated) { + return hooks.get(kind).stream() + .filter(r -> r.ownerNegotiated(negotiated) && r.prerequisitesMet(negotiated)) + .toList(); + } + + /** The reply decorations of the published providers, for budget validation. */ + public ReplyBudgetCatalog replyBudgetCatalog() { + return ReplyBudgetCatalog.fromDescriptors(providers.values()); + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/Tokens.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/Tokens.java new file mode 100644 index 0000000..862073f --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/Tokens.java @@ -0,0 +1,48 @@ +/* + * 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; + +/** Protocol-safe identifier rules shared by provider ids, capability names and tag keys. */ +final class Tokens { + static final int MAX_LENGTH = 64; + + private Tokens() {} + + static boolean isSafe(String token) { + if (token == null || token.isEmpty() || token.length() > MAX_LENGTH) return false; + for (int i = 0; i < token.length(); i++) { + char c = token.charAt(i); + boolean ok = + (c >= 'a' && c <= 'z') + || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') + || c == '-' + || c == '.' + || c == '/' + || c == '_'; + if (!ok) return false; + } + return true; + } + + static String require(String token, String what) { + if (!isSafe(token)) { + throw new IllegalArgumentException( + what + " must be 1-" + MAX_LENGTH + " characters of [A-Za-z0-9._/-]"); + } + return token; + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/package-info.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/package-info.java new file mode 100644 index 0000000..f78e39c --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/package-info.java @@ -0,0 +1,30 @@ +/* + * 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. + */ +/** + * The capability plugin SPI: how a capability provider declares typed hooks ({@link + * net.jircd.core.extension.capability.CapabilityRegistrar}), how they are published as an immutable + * {@link net.jircd.core.extension.capability.RuntimeSnapshot}, and how core runs them defensively + * ({@link net.jircd.core.extension.capability.HookDispatcher}). + * + *

Every hook result is untrusted and is validated against the bounds declared at registration + * before it can affect output. A faulty result is dropped, reported by provider id, hook kind and + * category only, and replaced by the documented default. This package is the foundation delivered + * with the server-time migration; the other providers still use the temporary {@code + * contributeTags}/{@code includeSenderInFanOut} bridge on {@link + * net.jircd.core.extension.CapabilityExtension}, and runtime FAILED withdrawal of a faulty provider + * is delivered later. + */ +package net.jircd.core.extension.capability; 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 bcdde73..f9d5f2a 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 @@ -19,12 +19,22 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import net.jircd.core.extension.CapabilityExtension; import net.jircd.core.extension.ExtensionRegistry; +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.RuntimeSnapshot; import net.jircd.protocol.CapabilityName; import net.jircd.protocol.MessageSerializer; -/** Selects negotiated enabled contributions at enqueue and renders that selection at drain. */ +/** + * Selects negotiated enabled contributions at enqueue and renders that selection at drain. Typed + * hook contributions are evaluated and validated at enqueue and shared immutably; no provider + * callback runs at drain for them. The legacy per-extension method is the temporary path for + * providers that have not migrated. + */ public final class CapabilityTagRenderer implements TagRenderer { private final ExtensionRegistry extensionRegistry; @@ -34,27 +44,70 @@ public CapabilityTagRenderer(ExtensionRegistry extensionRegistry) { @Override public PreparedTags prepare(ClientSession recipient, OutboundMessage message) { - if (recipient.negotiatedCapabilities().isEmpty()) - return TagRenderer.NONE.prepare(recipient, message); - boolean forwardClientTags = - recipient.negotiatedCapabilities().contains(CapabilityName.MESSAGE_TAGS); - List selected = new ArrayList<>(); + Set negotiated = recipient.negotiatedCapabilities(); + if (negotiated.isEmpty()) return TagRenderer.NONE.prepare(recipient, message); + boolean forwardClientTags = negotiated.contains(CapabilityName.MESSAGE_TAGS); WireBytes.Tags bytes = forwardClientTags ? message.clientTagBytes() : WireBytes.Tags.NONE; - for (var extension : extensionRegistry.enabled()) { - if (extension instanceof CapabilityExtension capability - && recipient.negotiatedCapabilities().contains(capability.providedCapability().name())) { - selected.add(capability); - bytes = bytes.plus(message.contributionBytes(capability)); + // 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(); + HookDispatcher dispatcher = extensionRegistry.hookDispatcher(); + Map composed = new LinkedHashMap<>(); + List> contributions = new ArrayList<>(); + for (CapabilityExtension capability : legacySelection(snapshot, negotiated)) { + OutboundMessage.Contribution contribution = + message.legacyContribution(capability, dispatcher); + if (fitsAlongside(composed, contribution.tags())) { + composed.putAll(contribution.tags()); + contributions.add(contribution.tags()); + bytes = bytes.plus(contribution.bytes()); + } else { + dispatcher.reportAggregateOverflow(capability.id()); } } - if (selected.isEmpty() && !forwardClientTags) + for (HookRegistration hook : snapshot.hooksFor(HookKind.OUTBOUND_TAGS, negotiated)) { + OutboundMessage.Contribution contribution = message.contribution(hook, dispatcher); + if (fitsAlongside(composed, contribution.tags())) { + composed.putAll(contribution.tags()); + contributions.add(contribution.tags()); + bytes = bytes.plus(contribution.bytes()); + } else { + // Each contribution fit its own bound; their aggregate must fit one origin as well. The + // latecomer is dropped and reported so the core message still goes out. + dispatcher.reportAggregateOverflow(hook); + } + } + if (contributions.isEmpty() && !forwardClientTags) { return TagRenderer.NONE.prepare(recipient, message); - List selection = List.copyOf(selected); + } + List> selected = List.copyOf(contributions); // Merging can collapse many overlapping contributions into one legal tag section. // No emitted section exceeds the protocol maximum, even when the summed bound does. return new PreparedTags( Math.min(bytes.wireBytes(), MessageSerializer.MAX_TAG_SECTION_BYTES), - () -> renderSelected(message, forwardClientTags, selection)); + () -> renderSelected(message, forwardClientTags, selected)); + } + + /** The enabled unmigrated providers of the captured snapshot that the recipient negotiated. */ + private List legacySelection( + RuntimeSnapshot snapshot, Set negotiated) { + List selected = new ArrayList<>(); + for (String providerId : snapshot.providerIds()) { + if (snapshot.hasHooks(providerId)) continue; + if (extensionRegistry.find(providerId).orElse(null) instanceof CapabilityExtension capability + && negotiated.contains(capability.providedCapability().name())) { + selected.add(capability); + } + } + return selected; + } + + private static boolean fitsAlongside(Map composed, Map added) { + if (composed.isEmpty()) return true; // one contribution was already checked against its bound + Map candidate = new LinkedHashMap<>(composed); + candidate.putAll(added); + return MessageSerializer.tagDataBytesAtMost( + candidate, MessageSerializer.MAX_TAG_DATA_BYTES_PER_ORIGIN); } @Override @@ -63,13 +116,12 @@ public Map render(ClientSession recipient, OutboundMessage messa } private static Map renderSelected( - OutboundMessage message, boolean forwardClientTags, List selection) { + OutboundMessage message, boolean forwardClientTags, List> contributions) { Map tags = new LinkedHashMap<>(); if (forwardClientTags) tags.putAll(message.forwardedClientTags()); checkOriginBudget(tags); Map serverTags = new LinkedHashMap<>(); - for (CapabilityExtension capability : selection) - serverTags.putAll(capability.contributeTags(message)); + contributions.forEach(serverTags::putAll); checkOriginBudget(serverTags); tags.putAll(serverTags); return tags; 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 7528fd1..383162e 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 @@ -39,6 +39,7 @@ import java.util.function.Supplier; import net.jircd.core.config.ConnectionLimitPolicy; import net.jircd.core.config.ServerConfiguration; +import net.jircd.core.extension.CommandRegistrar; import net.jircd.core.extension.ConnectionAdmissionExtension; import net.jircd.core.extension.Extension; import net.jircd.core.extension.ExtensionRegistry; @@ -288,6 +289,29 @@ public void registerHandler(Command command, CommandHandler handler) { handlers.put(command, handler); } + /** + * The extension-facing view of this command table: install, ownership query, conditional removal. + */ + public CommandRegistrar commandRegistrar() { + return new CommandRegistrar() { + @Override + public void register(Command command, CommandHandler handler) { + registerHandler(command, handler); + } + + @Override + public boolean isRegistered(Command command) { + return handlers.containsKey(command); + } + + @Override + public void unregister(Command command, CommandHandler installed) { + // Identity, not equals: a replacement that considers itself equal must survive. + handlers.computeIfPresent(command, (key, current) -> current == installed ? null : current); + } + }; + } + /** * 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 diff --git a/jircd-core/src/main/java/net/jircd/core/session/FrameSequence.java b/jircd-core/src/main/java/net/jircd/core/session/FrameSequence.java new file mode 100644 index 0000000..ab27466 --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/session/FrameSequence.java @@ -0,0 +1,69 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import net.jircd.protocol.Message; +import net.jircd.protocol.MessageSerializer; + +/** + * An immutable, fully materialized ordered run of wire lines and their exact size. The writer + * admits it as one queue entry, so either every line is queued or none is, and a closing frame can + * never be dropped independently of its opener. It holds only strings and a byte count: no plugin + * callback or mutable state reaches the queue. + */ +public final class FrameSequence { + private final List lines; + private final long bytes; + + private FrameSequence(List lines, long bytes) { + this.lines = lines; + this.bytes = bytes; + } + + /** + * Serializes {@code frames} now, so any size or syntax violation surfaces before admission. + * + * @throws IllegalArgumentException if {@code frames} is empty or a frame is not valid IRC syntax + * @throws MessageSerializer.MessageTooLongException if any frame cannot be represented + */ + public static FrameSequence of(List frames) { + if (frames.isEmpty()) { + throw new IllegalArgumentException("A frame sequence needs at least one frame"); + } + List lines = new ArrayList<>(frames.size()); + long total = 0; + for (Message frame : frames) { + FrameSyntax.validate(frame); + String line = MessageSerializer.serializeText(frame); + total = Math.addExact(total, (long) line.getBytes(StandardCharsets.UTF_8).length + 2); + lines.add(line); + } + return new FrameSequence(List.copyOf(lines), total); + } + + /** The serialized lines without line terminators. */ + public List lines() { + return lines; + } + + /** Exact bytes on the wire: tags, command, parameters and CRLF of every line. */ + public long bytes() { + return bytes; + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/session/FrameSyntax.java b/jircd-core/src/main/java/net/jircd/core/session/FrameSyntax.java new file mode 100644 index 0000000..973ca7a --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/session/FrameSyntax.java @@ -0,0 +1,107 @@ +/* + * 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.HashMap; +import java.util.Map; +import net.jircd.protocol.Message; +import net.jircd.protocol.MessageSerializer; + +/** + * Checks that a message built by extension code serializes to exactly one well-formed line: the + * serializer enforces sizes, not syntax, so a parameter carrying CR/LF would otherwise become extra + * wire messages. Also enforces the per-origin tag budget separately for server and client-only + * ({@code +}) tags. + */ +final class FrameSyntax { + private FrameSyntax() {} + + static void validate(Message message) { + String command = message.rawCommand() != null ? message.rawCommand() : message.command().name(); + if (!validCommand(command)) throw new IllegalArgumentException("Invalid command token"); + String prefix = message.prefix(); + if (prefix != null && (prefix.isEmpty() || hasAny(prefix, " \r\n\0"))) { + throw new IllegalArgumentException("Invalid prefix"); + } + int last = message.params().size() - 1; + for (int i = 0; i <= last; i++) { + String param = message.params().get(i); + if (hasAny(param, "\r\n\0")) throw new IllegalArgumentException("Invalid parameter"); + if (i < last && (param.isEmpty() || param.startsWith(":") || param.indexOf(' ') >= 0)) { + throw new IllegalArgumentException("Invalid middle parameter"); + } + } + validateTags(message.tags()); + } + + private static void validateTags(Map tags) { + Map server = new HashMap<>(); + Map client = new HashMap<>(); + for (var entry : tags.entrySet()) { + String key = entry.getKey(); + if (!validKey(key)) throw new IllegalArgumentException("Invalid tag key"); + // CR and LF are escaped by the serializer, but NUL would be emitted unchanged. + if (entry.getValue().indexOf('\0') >= 0) + throw new IllegalArgumentException("Invalid tag value"); + (key.startsWith("+") ? client : server).put(key, entry.getValue()); + } + for (Map origin : java.util.List.of(server, client)) { + if (!MessageSerializer.tagDataBytesAtMost( + origin, MessageSerializer.MAX_TAG_DATA_BYTES_PER_ORIGIN)) { + throw new IllegalArgumentException( + "Tag origin exceeds " + + MessageSerializer.MAX_TAG_DATA_BYTES_PER_ORIGIN + + " data bytes"); + } + } + } + + /** + * IRCv3 key: optional {@code +}, optional {@code vendor/} (a hostname), then + * letters-digits-hyphens. + */ + private static boolean validKey(String key) { + String body = key.startsWith("+") ? key.substring(1) : key; + int slash = body.indexOf('/'); + String name = slash < 0 ? body : body.substring(slash + 1); + if (slash >= 0 && !validVendor(body.substring(0, slash))) return false; + return !name.isEmpty() && name.chars().allMatch(c -> isAlnum(c) || c == '-'); + } + + private static boolean validVendor(String vendor) { + if (vendor.isEmpty() || vendor.startsWith(".") || vendor.endsWith(".")) return false; + return !vendor.contains("..") + && vendor.chars().allMatch(c -> isAlnum(c) || c == '-' || c == '.'); + } + + private static boolean isAlnum(int c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'); + } + + private static boolean validCommand(String command) { + if (command.isEmpty()) return false; + boolean digits = command.chars().allMatch(c -> c >= '0' && c <= '9'); + if (digits) return command.length() == 3; + return command.chars().allMatch(c -> (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')); + } + + private static boolean hasAny(String text, String chars) { + for (int i = 0; i < text.length(); i++) { + if (chars.indexOf(text.charAt(i)) >= 0) return true; + } + return false; + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/session/OutboundMessage.java b/jircd-core/src/main/java/net/jircd/core/session/OutboundMessage.java index 1e13291..7de8a6c 100644 --- a/jircd-core/src/main/java/net/jircd/core/session/OutboundMessage.java +++ b/jircd-core/src/main/java/net/jircd/core/session/OutboundMessage.java @@ -22,12 +22,18 @@ import java.util.Objects; import java.util.UUID; import net.jircd.core.extension.CapabilityExtension; +import net.jircd.core.extension.capability.FaultReporter; +import net.jircd.core.extension.capability.HookDispatcher; +import net.jircd.core.extension.capability.HookRegistration; /** * Immutable fan-out content shared by recipients, with message-scoped contribution byte metadata. * Recipient selection and rendering remain in the tag renderer; metadata never changes content. */ public final class OutboundMessage { + private static final HookDispatcher LEGACY_DISPATCHER = + new HookDispatcher(FaultReporter.logging()); + private final String senderPresentedForm; private final String command; private final String target; @@ -38,7 +44,11 @@ public final class OutboundMessage { private final long componentBytes; private final Map forwardedClientTags; private final WireBytes.Tags clientTagBytes; - private Map contributions; + private Map contributions; + private Map hookContributions; + + /** A hook's validated, immutable tags with their exact escaped size, computed once. */ + record Contribution(Map tags, WireBytes.Tags bytes) {} /** Creates shared message content, defensively copying client tags. */ public OutboundMessage( @@ -109,9 +119,40 @@ WireBytes.Tags clientTagBytes() { } synchronized WireBytes.Tags contributionBytes(CapabilityExtension capability) { + return legacyContribution(capability, LEGACY_DISPATCHER).bytes(); + } + + /** + * The legacy provider's tags, evaluated once per envelope so enqueue can budget them. A throw, a + * {@code null} or an oversized map is contained by {@code dispatcher}: an empty contribution. + */ + synchronized Contribution legacyContribution( + CapabilityExtension capability, HookDispatcher dispatcher) { if (contributions == null) contributions = new IdentityHashMap<>(); return contributions.computeIfAbsent( - capability, key -> WireBytes.tags(key.contributeTags(this))); + capability, + key -> { + Map tags = + dispatcher.legacyOutboundTags(key.id(), () -> key.contributeTags(this)); + return new Contribution(tags, WireBytes.tags(tags)); + }); + } + + /** + * The validated contribution of {@code hook} for this envelope. A pure hook yields the same map + * for the same immutable message, so it is evaluated once here and shared by every recipient; the + * registration identity is only the memoization key. Nothing evaluated here reaches the writer + * queue except as these immutable results. + */ + synchronized Contribution contribution(HookRegistration hook, HookDispatcher dispatcher) { + if (hookContributions == null) hookContributions = new IdentityHashMap<>(); + Contribution cached = hookContributions.get(hook); + if (cached == null) { + Map tags = dispatcher.outboundTags(hook, this); + cached = new Contribution(tags, WireBytes.tags(tags)); + hookContributions.put(hook, cached); + } + return cached; } @Override diff --git a/jircd-core/src/main/java/net/jircd/core/session/PreparedDelivery.java b/jircd-core/src/main/java/net/jircd/core/session/PreparedDelivery.java new file mode 100644 index 0000000..e29948c --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/session/PreparedDelivery.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.session; + +import java.util.Objects; +import net.jircd.core.extension.capability.RuntimeSnapshot; + +/** + * A delivery ready to queue: the validated frames plus the runtime view they were prepared under. + * Ordinary output is appended as prepared without revalidating against current state; a state + * change may overtake it. + */ +public record PreparedDelivery(FrameSequence frames, RuntimeSnapshot view) { + + public PreparedDelivery { + Objects.requireNonNull(frames); + Objects.requireNonNull(view); + } +} 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 3cfdd98..db9b8ab 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 @@ -117,6 +117,16 @@ public void enqueue(OutboundMessage message) { enqueueEntry(new Entry(message.componentBytes() + tags.wireBytes(), message, tags)); } + /** + * Enqueues a prepared delivery as one entry: all of its frames are admitted, or none is (the + * session overflows instead). Its exact byte charge is released once, when it is written or + * discarded. + */ + public void enqueue(PreparedDelivery delivery) { + if (closing.get() || overflowed.get()) return; + enqueueEntry(new Entry(delivery.frames().bytes(), delivery.frames(), null)); + } + /** Enqueues a pre-built line, charging its escaped tag and component bytes including CRLF. */ public void enqueueRaw(Message rawMessage) { if (closing.get() || overflowed.get()) return; @@ -260,14 +270,16 @@ private void writeMessage(Message message, boolean serverOriginOnly) { } } - private void writeLine(String line) { + private boolean writeLine(String line) { try { out.write((line + "\r\n").getBytes(StandardCharsets.UTF_8)); out.flush(); + return true; } catch (IOException e) { LOG.debug("Write failed for connection {}: {}", session.connectionId(), e.toString()); // A failed best-effort ERROR must not steal the SendQ reason from its pending cleanup. if (!overflowed.get()) onWriteFailure.get().run(); + return false; } } @@ -281,6 +293,10 @@ private void drainLoop() { } if (entry.message() instanceof Message raw) { writeMessage(raw, true); + } else if (entry.message() instanceof FrameSequence sequence) { + for (String line : sequence.lines()) { + if (!writeLine(line)) break; + } } else { writeFanOutMessage((OutboundMessage) entry.message(), entry.tags()); } diff --git a/jircd-core/src/main/java/net/jircd/core/session/WarningThrottle.java b/jircd-core/src/main/java/net/jircd/core/session/WarningThrottle.java index 339a8d2..0eb2672 100644 --- a/jircd-core/src/main/java/net/jircd/core/session/WarningThrottle.java +++ b/jircd-core/src/main/java/net/jircd/core/session/WarningThrottle.java @@ -27,6 +27,7 @@ public final class WarningThrottle { private static final class State { + boolean admittedOnce; long lastAdmittedNanos; long suppressed; } @@ -53,17 +54,10 @@ protected boolean removeEldestEntry(Map.Entry eldest) { */ public synchronized long admit(String key) { long now = nanoClock.getAsLong(); - boolean[] created = {false}; - State state = - states.computeIfAbsent( - key, - _ -> { - created[0] = true; - State fresh = new State(); - fresh.lastAdmittedNanos = now; - return fresh; - }); - if (created[0]) { + State state = states.computeIfAbsent(key, _ -> new State()); + if (!state.admittedOnce) { + state.admittedOnce = true; + state.lastAdmittedNanos = now; return 0; } if (now - state.lastAdmittedNanos < intervalNanos) { 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 11c2b16..8cea73f 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 @@ -16,6 +16,7 @@ package net.jircd.core.capability; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.List; import net.jircd.core.extension.AbstractCapabilityExtension; @@ -125,12 +126,14 @@ void isAcceptableDoesNotChangeTheSession() { } @Test - void twoExtensionsOfferingTheSameNameDoNotBreakRequests() { + void aSecondExtensionOfferingTheSameNameIsRejectedAndRequestsStillWork() { ExtensionRegistry registry = new ExtensionRegistry(); registry.register(new DuplicateOffer("first")); registry.register(new DuplicateOffer("second")); registry.enable("first"); - registry.enable("second"); + assertThatThrownBy(() -> registry.enable("second")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("shared"); CapabilityNegotiator negotiator = new CapabilityNegotiator(registry); ClientSession session = new ClientSession("c1", "host.example", RateLimitBucket.withDefaults()); diff --git a/jircd-core/src/test/java/net/jircd/core/config/CapabilityReplyBudgetTest.java b/jircd-core/src/test/java/net/jircd/core/config/CapabilityReplyBudgetTest.java new file mode 100644 index 0000000..f05bf16 --- /dev/null +++ b/jircd-core/src/test/java/net/jircd/core/config/CapabilityReplyBudgetTest.java @@ -0,0 +1,219 @@ +/* + * 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.List; +import java.util.Map; +import java.util.Set; +import net.jircd.core.extension.ExtensionRegistry; +import net.jircd.core.extension.capability.ReplyBudgetCatalog; +import net.jircd.core.extension.capability.ReplyContribution; +import net.jircd.core.extension.capability.ReplyLayout; +import net.jircd.core.extension.capability.ReplyLimits; +import net.jircd.core.extension.capability.ReplySlot; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** Generic reply layouts and slot bounds replace capability-specific width assumptions. */ +class CapabilityReplyBudgetTest { + @TempDir Path directory; + + private static final int S = 16; // "test.jircd.local" + private static final ReplyLimits LIMITS = ReplyLimits.of(S, 9, 50); + + private static ReplyBudgetCatalog catalog(Object... slotAndBytes) { + List list = new java.util.ArrayList<>(); + for (int i = 0; i < slotAndBytes.length; i += 3) { + long bytes = ((Number) slotAndBytes[i + 2]).longValue(); + list.add( + new ReplyContribution( + (String) slotAndBytes[i], (ReplySlot) slotAndBytes[i + 1], l -> bytes)); + } + return new ReplyBudgetCatalog(list); + } + + @Test + void coreOnlyHighestStatusReservesOneStatusByte() { + ReplyBudgetCatalog core = ReplyBudgetCatalog.empty(); + assertThat(core.worstCaseBytes(ReplyLayout.NAMES_MEMBER, LIMITS)) + .isEqualTo(S + 2 * 9 + 50 + 14); + assertThat(core.worstCaseBytes(ReplyLayout.NAMES_END, LIMITS)).isEqualTo(S + 9 + 50 + 29); + } + + @Test + void twoSymbolStatusReproducesTheCurrentSafeEnvelope() { + ReplyBudgetCatalog legacy = ReplyBudgetCatalog.legacyCompatible(); + assertThat(legacy.worstCaseBytes(ReplyLayout.NAMES_MEMBER, LIMITS)) + .isEqualTo(S + 2 * 9 + 50 + 15); + int u = ServerConfiguration.IDENT_MAX_BYTES; + int h = ServerConfiguration.PRESENTED_HOST_MAX_BYTES; + // WHO with F = 2 + P = 4 is exactly the previously hard-coded assumption. + assertThat(legacy.worstCaseBytes(ReplyLayout.WHO_ENTRY, LIMITS)) + .isEqualTo(2 * S + 2 * 9 + 50 + u + h + 22); + assertThat(HostmaskReplyBudget.worstCaseBytes(S, 9, 50)) + .containsEntry("352 (WHO)", 2 * S + 2 * 9 + 50 + u + h + 22); + } + + @Test + void replacementSlotsReserveTheirMaximumAndIndependentSlotsSum() { + ReplyBudgetCatalog catalog = + catalog("a", ReplySlot.STATUS, 2, "b", ReplySlot.STATUS, 3, "c", ReplySlot.IDENTITY, 40); + // Only one provider can own STATUS, so the conservative reservation is the larger one (3), not + // 5. + assertThat(catalog.slotBytes(LIMITS)) + .containsEntry(ReplySlot.STATUS, 3L) + .containsEntry(ReplySlot.IDENTITY, 40L); + assertThat(catalog.worstCaseBytes(ReplyLayout.NAMES_MEMBER, LIMITS)) + .isEqualTo(S + 2 * 9 + 50 + 13 + 3 + 40); + } + + @Test + void identityBytesComposeWithTheStatusReservationInsteadOfBeingCheckedAlone() { + int u = ServerConfiguration.IDENT_MAX_BYTES; + int h = ServerConfiguration.PRESENTED_HOST_MAX_BYTES; + ReplyBudgetCatalog both = + catalog("mp", ReplySlot.STATUS, 2, "uh", ReplySlot.IDENTITY, u + h + 2); + ReplyBudgetCatalog identityOnly = catalog("uh", ReplySlot.IDENTITY, u + h + 2); + assertThat(both.worstCaseBytes(ReplyLayout.NAMES_MEMBER, LIMITS)) + .isEqualTo(identityOnly.worstCaseBytes(ReplyLayout.NAMES_MEMBER, LIMITS) + 1); + } + + @Test + void invalidDeclaredBoundsAreFaultsNotZeroByteDefaults() { + for (long bad : new long[] {-1, 513}) { + ReplyBudgetCatalog catalog = catalog("p", ReplySlot.STATUS, bad); + assertThatThrownBy(() -> catalog.slotBytes(LIMITS)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("'p'"); + } + ReplyBudgetCatalog throwing = + new ReplyBudgetCatalog( + List.of( + new ReplyContribution( + "p", + ReplySlot.STATUS, + l -> { + throw new IllegalStateException("x"); + }))); + assertThatThrownBy(() -> throwing.slotBytes(LIMITS)) + .isInstanceOf(IllegalArgumentException.class); + } + + // ---- startup / reload / enable parity + + private static final ReplyBudgetCatalog WIDE = catalog("wide", ReplySlot.IDENTITY, 200); + + private ConfigurationLoader loader() { + return new ConfigurationLoader(Set.of(), Set.of(), () -> WIDE); + } + + private static final String YAML = + "serverName: test.jircd.local\nnicknameMaxLength: 100\nchannelNameMaxLength: 50\n"; + + @Test + void startupReloadAndEnableUseTheSameInstalledCatalogBounds() throws Exception { + // 353 = 16 + 2*150 + 50 + 15 = 381 fits alone; the provider's 200 identity bytes make it 581. + String tight = + "serverName: test.jircd.local\nnicknameMaxLength: 150\nchannelNameMaxLength: 50\n"; + // Without the installed provider the same configuration is accepted ... + assertThat( + new ConfigurationLoader(Set.of(), Set.of()) + .load(new ByteArrayInputStream(tight.getBytes(StandardCharsets.UTF_8))) + .nicknameMaxLength()) + .isEqualTo(150); + // ... startup rejects it once the provider's identity bytes are reserved ... + assertThatThrownBy( + () -> loader().load(new ByteArrayInputStream(tight.getBytes(StandardCharsets.UTF_8)))) + .isInstanceOf(ConfigurationException.class) + .hasMessageContaining("353="); + // ... and so does reload, with the previous configuration untouched. + ConfigurationLoader plain = new ConfigurationLoader(Set.of(), Set.of()); + ServerConfiguration initial = + plain.load( + new ByteArrayInputStream( + "serverName: test.jircd.local\n".getBytes(StandardCharsets.UTF_8))); + Path yaml = directory.resolve("tight.yaml"); + Files.writeString(yaml, tight); + ConfigurationReloader reloader = + new ConfigurationReloader(yaml, loader(), new ExtensionRegistry(), initial); + assertThatThrownBy(reloader::reload) + .isInstanceOf(ConfigurationException.class) + .hasMessageContaining("353="); + assertThat(reloader.current()).isSameAs(initial); + } + + @Test + void validConfigurationWithTheInstalledCatalogStillLoads() throws Exception { + assertThat( + loader() + .load(new ByteArrayInputStream(YAML.getBytes(StandardCharsets.UTF_8))) + .nicknameMaxLength()) + .isEqualTo(100); + } + + @Test + void namesFitComputationReportsTheRemainingChannelBudget() { + assertThatThrownBy( + () -> + ConfigurationLoader.validateNamesBudget( + "test.jircd.local", 216, 51, ReplyBudgetCatalog.legacyCompatible())) + .isInstanceOf(ConfigurationException.class) + .hasMessageContaining("353=514") + .hasMessageContaining("budget="); + Map names = + HostmaskReplyBudget.namesWorstCaseBytes(16, 215, 51, ReplyBudgetCatalog.legacyCompatible()); + assertThat(names).containsEntry("353", 512); + } + + // ---- a bound that is fine at the reference limits but not at the configured ones + + @Test + void aBoundThatExceedsOrThrowsAtTheConfiguredLimitsIsAValidationErrorNotAnIllegalArgument() { + ReplyBudgetCatalog tooWide = + new ReplyBudgetCatalog( + List.of( + new ReplyContribution( + "wide", ReplySlot.IDENTITY, l -> l.nicknameMax() > 100 ? 600 : 10))); + ReplyBudgetCatalog throwing = + new ReplyBudgetCatalog( + List.of( + new ReplyContribution( + "boom", + ReplySlot.IDENTITY, + l -> { + if (l.nicknameMax() > 100) throw new IllegalStateException("no"); + return 10; + }))); + String configured = + "serverName: test.jircd.local\nnicknameMaxLength: 150\nchannelNameMaxLength: 50\n"; + for (ReplyBudgetCatalog catalog : List.of(tooWide, throwing)) { + ConfigurationLoader loader = new ConfigurationLoader(Set.of(), Set.of(), () -> catalog); + assertThatThrownBy( + () -> + loader.load( + new ByteArrayInputStream(configured.getBytes(StandardCharsets.UTF_8)))) + .isInstanceOf(ConfigurationException.class) + .hasMessageContaining("reply bound"); + } + } +} diff --git a/jircd-core/src/test/java/net/jircd/core/config/ConfigurationReloaderLinkageTest.java b/jircd-core/src/test/java/net/jircd/core/config/ConfigurationReloaderLinkageTest.java new file mode 100644 index 0000000..a79ee67 --- /dev/null +++ b/jircd-core/src/test/java/net/jircd/core/config/ConfigurationReloaderLinkageTest.java @@ -0,0 +1,152 @@ +/* + * 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 static org.assertj.core.api.Assertions.catchThrowable; + +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.extension.Extension; +import net.jircd.core.extension.ExtensionRegistry; +import net.jircd.core.extension.ServerContext; +import net.jircd.core.extension.ServerExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** A provider whose class cannot link fails alone; the rest of the REHASH still applies. */ +class ConfigurationReloaderLinkageTest { + @TempDir Path directory; + + private static final class Simple implements ServerExtension { + private final String id; + private final boolean broken; + + Simple(String id, boolean broken) { + this.id = id; + this.broken = broken; + } + + @Override + public String id() { + return id; + } + + @Override + public void start(ServerContext context) { + if (broken) throw new NoClassDefFoundError("missing/Dependency"); + } + + @Override + public void stop() { + // No resources. + } + } + + @Test + void aLinkageErrorWhileEnablingOneProviderDoesNotAbortTheReload() throws Exception { + ConfigurationLoader loader = new ConfigurationLoader(Set.of(), Set.of("broken", "good")); + ServerConfiguration initial = + loader.load( + new ByteArrayInputStream( + "serverName: test.jircd.local\n".getBytes(StandardCharsets.UTF_8))); + ExtensionRegistry extensions = new ExtensionRegistry(); + extensions.register(new Simple("broken", true)); + extensions.register(new Simple("good", false)); + Path yaml = directory.resolve("reload.yaml"); + Files.writeString( + yaml, + """ + serverName: test.jircd.local + nicknameMaxLength: 20 + server-extensions: {broken: enabled, good: enabled} + """); + ConfigurationReloader reloader = new ConfigurationReloader(yaml, loader, extensions, initial); + + ServerConfiguration reloaded = reloader.reload(); + + assertThat(reloader.current()).isSameAs(reloaded); + assertThat(reloaded.nicknameMaxLength()).isEqualTo(20); + assertThat(extensions.stateOf("broken")).isEqualTo(Extension.State.FAILED); + assertThat(extensions.stateOf("good")).isEqualTo(Extension.State.ENABLED); + } + + private static final class Flaky implements ServerExtension { + volatile RuntimeException stopFailure; + volatile Runnable onStart = () -> {}; + + @Override + public String id() { + return "flaky"; + } + + @Override + public void start(ServerContext context) { + onStart.run(); + } + + @Override + public void stop() { + if (stopFailure != null) throw stopFailure; + } + } + + private static final String HEADER = "serverName: test.jircd.local\n"; + + @Test + void rehashRetriesTheCleanupOfAFailedProviderWhoseDesiredStateIsDisabled() throws Exception { + ConfigurationLoader loader = new ConfigurationLoader(Set.of(), Set.of("flaky")); + ServerConfiguration initial = + loader.load(new ByteArrayInputStream(HEADER.getBytes(StandardCharsets.UTF_8))); + Flaky flaky = new Flaky(); + ExtensionRegistry extensions = new ExtensionRegistry(); + extensions.register(flaky); + extensions.enable("flaky"); + flaky.stopFailure = new IllegalStateException("stuck"); + assertThatThrownBy(() -> extensions.disable("flaky")).isSameAs(flaky.stopFailure); + flaky.stopFailure = null; + Path yaml = directory.resolve("retry.yaml"); + Files.writeString(yaml, HEADER + "server-extensions: {flaky: disabled}\n"); + + new ConfigurationReloader(yaml, loader, extensions, initial).reload(); + + assertThat(extensions.stateOf("flaky")).isEqualTo(Extension.State.DISABLED); + } + + @Test + void aProviderCallbackThatCallsReloadFailsFastInsteadOfDeadlocking() throws Exception { + ConfigurationLoader loader = new ConfigurationLoader(Set.of(), Set.of("flaky")); + ServerConfiguration initial = + loader.load(new ByteArrayInputStream(HEADER.getBytes(StandardCharsets.UTF_8))); + Path yaml = directory.resolve("reentrant.yaml"); + Files.writeString(yaml, HEADER); + Flaky flaky = new Flaky(); + ExtensionRegistry extensions = new ExtensionRegistry(); + extensions.register(flaky); + ConfigurationReloader reloader = new ConfigurationReloader(yaml, loader, extensions, initial); + java.util.concurrent.atomic.AtomicReference attempt = + new java.util.concurrent.atomic.AtomicReference<>(); + flaky.onStart = () -> attempt.set(catchThrowable(reloader::reload)); + + extensions.enable("flaky"); + + assertThat(attempt.get()).isInstanceOf(IllegalStateException.class); + } +} 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 new file mode 100644 index 0000000..b59e591 --- /dev/null +++ b/jircd-core/src/test/java/net/jircd/core/extension/ExtensionRegistryCapabilityTest.java @@ -0,0 +1,856 @@ +/* + * 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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import net.jircd.core.capability.Capability; +import net.jircd.core.extension.capability.CapabilityRegistrar; +import net.jircd.core.extension.capability.HookKind; +import net.jircd.core.extension.capability.ReplyBudgetCatalog; +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; + +/** Staging, atomic publication, withdrawal and failure cleanup of capability hooks. */ +class ExtensionRegistryCapabilityTest { + + private static final class Provider implements CapabilityExtension { + final String id; + final Consumer staging; + final RuntimeException startFailure; + volatile RuntimeException stopFailure; + volatile Error startError; + final AtomicInteger starts = new AtomicInteger(); + final AtomicInteger stops = new AtomicInteger(); + + Provider(String id, Consumer staging, RuntimeException startFailure) { + this.id = id; + this.staging = staging; + this.startFailure = startFailure; + } + + @Override + public String id() { + return id; + } + + @Override + public void start(ServerContext context) { + starts.incrementAndGet(); + if (startFailure != null) throw startFailure; + if (startError != null) throw startError; + } + + @Override + public void stop() { + stops.incrementAndGet(); + if (stopFailure != null) throw stopFailure; + } + + @Override + public Capability providedCapability() { + return new Capability(id); + } + + @Override + public void register(CapabilityRegistrar registrar) { + staging.accept(registrar); + } + } + + private static Provider tagProvider(String id, String key) { + return new Provider( + id, r -> r.onOutboundTags(Set.of(key), 16, message -> Map.of(key, "v")), null); + } + + private final List registeredCommands = new ArrayList<>(); + 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); + } + + @Override + public boolean isRegistered(Command command) { + return handlers.containsKey(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); + } + }; + + private ExtensionRegistry registry(String serverName) { + ExtensionRegistry registry = new ExtensionRegistry(); + registry.attachContext( + new ServerContext( + null, + null, + registry, + () -> serverName, + null, + null, + commands, + registry.capabilityServices())); + return registry; + } + + @Test + void enablePublishesTheHooksAndDisableWithdrawsThemBeforeStop() { + ExtensionRegistry registry = registry("test.jircd.local"); + Provider provider = tagProvider("alpha", "a"); + registry.register(provider); + registry.enable("alpha"); + assertThat(registry.capabilityServices().snapshot().providerIds()).containsExactly("alpha"); + assertThat(registry.capabilitySnapshot().hooks(HookKind.OUTBOUND_TAGS)).hasSize(1); + registry.disable("alpha"); + assertThat(registry.capabilitySnapshot().providerIds()).isEmpty(); + assertThat(provider.stops).hasValue(1); + assertThat(registry.stateOf("alpha")).isEqualTo(Extension.State.DISABLED); + } + + @Test + void aStopFailureAfterWithdrawalKeepsTheProviderWithdrawnAndRecordsItFailed() { + ExtensionRegistry registry = registry("test.jircd.local"); + Provider provider = tagProvider("alpha", "a"); + registry.register(provider); + registry.enable("alpha"); + provider.stopFailure = new IllegalStateException("stop failed"); + assertThatThrownBy(() -> registry.disable("alpha")).isInstanceOf(IllegalStateException.class); + assertThat(registry.stateOf("alpha")).isEqualTo(Extension.State.FAILED); + assertThat(registry.capabilitySnapshot().providerIds()).isEmpty(); + provider.stopFailure = null; + registry.enable("alpha"); + assertThat(registry.stateOf("alpha")).isEqualTo(Extension.State.ENABLED); + assertThat(registry.capabilitySnapshot().providerIds()).containsExactly("alpha"); + } + + @Test + void aProviderThatStagesNothingPublishesAnEmptyDescriptor() { + ExtensionRegistry registry = registry("test.jircd.local"); + registry.register(new Provider("legacy", r -> {}, null)); + registry.enable("legacy"); + assertThat(registry.capabilitySnapshot().descriptor("legacy")).isPresent(); + } + + @Test + void invalidStagingFailsTheProviderPublishesNothingAndStopsItsResources() { + ExtensionRegistry registry = registry("test.jircd.local"); + Provider bad = + new Provider( + "bad", + r -> { + r.onOutboundTags(Set.of("a"), 16, m -> Map.of()); + r.onOutboundTags(Set.of("a"), 16, m -> Map.of()); // duplicate key + }, + null); + registry.register(bad); + registry.register(tagProvider("good", "g")); + assertThatThrownBy(() -> registry.enable("bad")).isInstanceOf(IllegalArgumentException.class); + registry.enable("good"); + assertThat(registry.stateOf("bad")).isEqualTo(Extension.State.FAILED); + assertThat(bad.stops).hasValue(1); + assertThat(registry.capabilitySnapshot().providerIds()).containsExactly("good"); + } + + @Test + void aStartFailureStillCleansUpWhateverWasAcquiredBeforeItThrew() { + ExtensionRegistry registry = registry("test.jircd.local"); + Provider broken = new Provider("broken", r -> {}, new IllegalStateException("no")); + registry.register(broken); + assertThatThrownBy(() -> registry.enable("broken")).isInstanceOf(IllegalStateException.class); + assertThat(registry.stateOf("broken")).isEqualTo(Extension.State.FAILED); + assertThat(broken.stops).hasValue(1); + assertThat(registry.capabilitySnapshot().providerIds()).isEmpty(); + } + + @Test + void aLinkageErrorWhileStagingFailsTheProviderAndStopsItsResources() { + ExtensionRegistry registry = registry("test.jircd.local"); + Provider broken = + new Provider( + "linked", + r -> { + throw new NoClassDefFoundError("missing"); + }, + null); + registry.register(broken); + assertThatThrownBy(() -> registry.enable("linked")).isInstanceOf(NoClassDefFoundError.class); + assertThat(registry.stateOf("linked")).isEqualTo(Extension.State.FAILED); + assertThat(broken.starts).hasValue(1); + assertThat(broken.stops).hasValue(1); + assertThat(registry.capabilitySnapshot().providerIds()).isEmpty(); + } + + @Test + void aConflictingProviderIsRejectedWithoutDisturbingTheEnabledOne() { + ExtensionRegistry registry = registry("test.jircd.local"); + registry.register(tagProvider("first", "shared")); + registry.register(tagProvider("second", "shared")); + registry.enable("first"); + assertThatThrownBy(() -> registry.enable("second")) + .isInstanceOf(IllegalArgumentException.class); + assertThat(registry.stateOf("first")).isEqualTo(Extension.State.ENABLED); + assertThat(registry.stateOf("second")).isEqualTo(Extension.State.FAILED); + assertThat(registry.capabilitySnapshot().providerIds()).containsExactly("first"); + } + + @Test + void disablingAProviderThatAnotherRequiresIsRefusedAndChangesNothing() { + 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"); + } + + @Test + void enablingWithoutItsRequiredProviderFails() { + ExtensionRegistry registry = registry("test.jircd.local"); + registry.register(new Provider("dependent", r -> r.requiresProviders("base"), null)); + assertThatThrownBy(() -> registry.enable("dependent")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("base"); + } + + @Test + void providerCommandsAreInstalledOnEnableAndRemovedOnDisable() { + 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"); + ClientSession session = new ClientSession("c", "host", RateLimitBucket.withDefaults()); + handlers.get(Command.TAGMSG).handle(session, Message.of(Command.TAGMSG, "#c")); + assertThat(handled).hasValue(1); + registry.disable("cmd"); + assertThat(handlers).doesNotContainKey(Command.TAGMSG); + registry.enable("cmd"); + assertThat(handlers).containsKey(Command.TAGMSG); + } + + @Test + void aProviderCannotReplaceACommandCoreOrAnotherExtensionAlreadyOwns() { + ExtensionRegistry registry = registry("test.jircd.local"); + CommandHandler core = (session, message) -> {}; + handlers.put(Command.JOIN, core); + registry.register(new Provider("greedy", r -> r.command(Command.JOIN, (s, m) -> {}), null)); + assertThatThrownBy(() -> registry.enable("greedy")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("JOIN"); + assertThat(handlers).containsEntry(Command.JOIN, core); + assertThat(registry.stateOf("greedy")).isEqualTo(Extension.State.FAILED); + assertThat(registry.capabilitySnapshot().providerIds()).isEmpty(); + registry.disable("greedy"); + 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 { + assertThat( + java.lang.reflect.Modifier.isAbstract( + CommandRegistrar.class + .getMethod("unregister", Command.class, CommandHandler.class) + .getModifiers())) + .isTrue(); + assertThat( + java.lang.reflect.Modifier.isAbstract( + CommandRegistrar.class.getMethod("isRegistered", Command.class).getModifiers())) + .isTrue(); + } + + @Test + void disablingAProviderLeavesAReplacementInstalledByAnotherExtensionAlone() { + ExtensionRegistry registry = registry("test.jircd.local"); + registry.register(new Provider("early", r -> r.command(Command.OPER, (s, m) -> {}), null)); + registry.enable("early"); + CommandHandler replacement = (session, message) -> {}; + handlers.put(Command.OPER, replacement); // e.g. admin enabled later and took OPER over + registry.disable("early"); + 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"); + registry.register(tagProvider("alpha", "a")); + registry.enable("alpha"); + var services = registry.capabilityServices(); + assertThat(services.snapshot().providerIds()).containsExactly("alpha"); + for (var method : services.getClass().getMethods()) { + assertThat(method.getReturnType().getSimpleName()).isNotEqualTo("CapabilityRuntime"); + } + for (var method : ExtensionRegistry.class.getMethods()) { + assertThat(method.getReturnType().getSimpleName()).isNotEqualTo("CapabilityRuntime"); + } + } + + @Test + void duplicateProviderIdsAreRejectedAtRegistrationAndDiscoveryReportsThem( + @org.junit.jupiter.api.io.TempDir java.nio.file.Path dir) throws Exception { + ExtensionRegistry direct = new ExtensionRegistry(); + direct.register(tagProvider("same", "a")); + Provider other = tagProvider("same", "b"); + assertThatThrownBy(() -> direct.register(other)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("same"); + + java.nio.file.Path services = dir.resolve("META-INF/services"); + java.nio.file.Files.createDirectories(services); + java.nio.file.Files.writeString( + services.resolve(CapabilityExtension.class.getName()), + DupOne.class.getName() + + "\n" + + DupTwo.class.getName() + + "\n" + + Unique.class.getName() + + "\n"); + ExtensionRegistry discovered = new ExtensionRegistry(); + ch.qos.logback.classic.Logger logger = + (ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory.getLogger(ExtensionRegistry.class); + ch.qos.logback.core.read.ListAppender appender = + new ch.qos.logback.core.read.ListAppender<>(); + appender.start(); + logger.addAppender(appender); + try (var loader = + new java.net.URLClassLoader( + new java.net.URL[] {dir.toUri().toURL()}, getClass().getClassLoader())) { + discovered.discover(loader); + } finally { + logger.detachAppender(appender); + } + assertThat(appender.list) + .anySatisfy( + event -> + assertThat(event.getFormattedMessage()) + .contains("DupTwo") + .contains("Duplicate extension id: dup")); + assertThat(discovered.all()) + .extracting(Extension::id) + .containsExactlyInAnyOrder("dup", "unique"); + assertThat(discovered.find("dup").orElseThrow()).isInstanceOf(DupOne.class); + } + + public static class DupOne extends Named { + public DupOne() { + super("dup"); + } + } + + public static class DupTwo extends Named { + public DupTwo() { + super("dup"); + } + } + + public static class Unique extends Named { + public Unique() { + super("unique"); + } + } + + public abstract static class Named implements CapabilityExtension { + private final String id; + + Named(String id) { + this.id = id; + } + + @Override + public String id() { + return id; + } + + @Override + public void start(ServerContext context) { + // No resources. + } + + @Override + public void stop() { + // No resources. + } + + @Override + public Capability providedCapability() { + return new Capability(id); + } + } + + @Test + void aFaultingProviderCommandIsContainedAtTheCoreBoundary() { + ExtensionRegistry registry = registry("test.jircd.local"); + CommandHandler failing = + (session, message) -> { + throw new IllegalStateException("boom"); + }; + 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); + 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 + // bytes. + ExtensionRegistry registry = registry("s".repeat(200) + ".net"); + registry.register( + new Provider("wide", r -> r.onNamesIdentity(limits -> 300, m -> m.nickname()), null)); + assertThatThrownBy(() -> registry.enable("wide")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("353"); + assertThat(registry.stateOf("wide")).isEqualTo(Extension.State.FAILED); + assertThat(registry.capabilitySnapshot().providerIds()).isEmpty(); + } + + @Test + void discoveredCatalogCoversDisabledProvidersAndSkipsOnesThatCannotStage() { + ExtensionRegistry registry = registry("test.jircd.local"); + registry.register(new Provider("decorates", r -> r.onMemberStatus(limits -> 2, m -> ""), null)); + registry.register( + new Provider( + "broken", + r -> { + throw new IllegalStateException("cannot stage"); + }, + null)); + ReplyBudgetCatalog catalog = registry.discoveredReplyBudgetCatalog(); + assertThat(catalog.contributions()).hasSize(1); + assertThat(registry.stateOf("decorates")).isEqualTo(Extension.State.DISABLED); + } + + @Test + void stagingWithoutServerContextStillWorksForUnitSetups() { + ExtensionRegistry registry = new ExtensionRegistry(); + registry.register(tagProvider("alpha", "a")); + registry.enable("alpha"); + assertThat(registry.capabilitySnapshot().providerIds()).containsExactly("alpha"); + } + + // ---- 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"); + boolean[] otherThreadFinished = {false}; + String[] readResult = {null}; + registry.register( + new CapabilityExtension() { + @Override + public String id() { + return "blocker"; + } + + @Override + public void start(ServerContext context) { + CountDownLatch done = new CountDownLatch(1); + Thread other = + new Thread( + () -> { + synchronized (registry) { + readResult[0] = + registry.enabled().size() + "/" + registry.stateOf("blocker"); + done.countDown(); + } + }); + other.start(); + try { + otherThreadFinished[0] = done.await(5, TimeUnit.SECONDS); + } catch (InterruptedException _) { + Thread.currentThread().interrupt(); + } + } + + @Override + public void stop() { + // No resources. + } + + @Override + public Capability providedCapability() { + return new Capability("blocker"); + } + }); + registry.enable("blocker"); + assertThat(otherThreadFinished[0]).isTrue(); + assertThat(readResult[0]).isNotNull(); + } + + @Test + void anyErrorFromStartOrStagingStillStopsTheProviderAndRecordsItFailed() { + ExtensionRegistry registry = registry("test.jircd.local"); + Provider failingStart = new Provider("start", r -> {}, null); + failingStart.startError = new AssertionError("start"); + Provider failingStaging = + new Provider( + "stage", + r -> { + throw new AssertionError("stage"); + }, + null); + registry.register(failingStart); + registry.register(failingStaging); + assertThatThrownBy(() -> registry.enable("start")).isInstanceOf(AssertionError.class); + assertThatThrownBy(() -> registry.enable("stage")).isInstanceOf(AssertionError.class); + assertThat(failingStart.stops).hasValue(1); + assertThat(failingStaging.stops).hasValue(1); + assertThat(registry.stateOf("start")).isEqualTo(Extension.State.FAILED); + assertThat(registry.stateOf("stage")).isEqualTo(Extension.State.FAILED); + assertThat(registry.capabilitySnapshot().providerIds()).isEmpty(); + } + + private static final class PointOwner implements ServerExtension { + private final String id; + volatile RuntimeException stopFailure; + volatile RuntimeException startFailure; + + PointOwner(String id) { + this.id = id; + } + + @Override + public String id() { + return id; + } + + @Override + public String extensionPoint() { + return "shared-point"; + } + + @Override + public void start(ServerContext context) { + if (startFailure != null) throw startFailure; + } + + @Override + public void stop() { + if (stopFailure != null) throw stopFailure; + } + } + + @Test + void aFailedStopKeepsTheExtensionPointClaimUntilARetriedDisableSucceeds() { + ExtensionRegistry registry = registry("test.jircd.local"); + PointOwner first = new PointOwner("first"); + registry.register(first); + registry.register(new PointOwner("second")); + registry.enable("first"); + first.stopFailure = new IllegalStateException("stuck"); + assertThatThrownBy(() -> registry.disable("first")).isSameAs(first.stopFailure); + assertThat(registry.stateOf("first")).isEqualTo(Extension.State.FAILED); + assertThatThrownBy(() -> registry.enable("second")) + .isInstanceOf(ExtensionRegistry.ExtensionPointConflictException.class); + first.stopFailure = null; + registry.disable("first"); + assertThat(registry.stateOf("first")).isEqualTo(Extension.State.DISABLED); + registry.enable("second"); + assertThat(registry.stateOf("second")).isEqualTo(Extension.State.ENABLED); + } + + @Test + void aFailedReEnableAfterAFailedStopFinishesThePendingCleanupAndReleasesTheClaim() { + ExtensionRegistry registry = registry("test.jircd.local"); + PointOwner first = new PointOwner("first"); + registry.register(first); + registry.register(new PointOwner("second")); + registry.enable("first"); + first.stopFailure = new IllegalStateException("stuck"); + assertThatThrownBy(() -> registry.disable("first")).isSameAs(first.stopFailure); + first.stopFailure = null; + first.startFailure = new IllegalStateException("will not start"); + assertThatThrownBy(() -> registry.enable("first")).isSameAs(first.startFailure); + assertThat(registry.stateOf("first")).isEqualTo(Extension.State.FAILED); + registry.enable("second"); + assertThat(registry.stateOf("second")).isEqualTo(Extension.State.ENABLED); + } + + @Test + void aProviderCallbackCannotReEnterTheLifecycle() { + ExtensionRegistry registry = registry("test.jircd.local"); + registry.register(tagProvider("other", "o")); + java.util.concurrent.atomic.AtomicReference enableAttempt = + new java.util.concurrent.atomic.AtomicReference<>(); + java.util.concurrent.atomic.AtomicReference disableAttempt = + new java.util.concurrent.atomic.AtomicReference<>(); + registry.register( + new CapabilityExtension() { + @Override + public String id() { + return "reentrant"; + } + + @Override + public void start(ServerContext context) { + enableAttempt.set(catchThrowable(() -> registry.enable("other"))); + disableAttempt.set(catchThrowable(() -> registry.disable("other"))); + } + + @Override + public void stop() { + // No resources. + } + + @Override + public Capability providedCapability() { + return new Capability("reentrant"); + } + }); + registry.enable("reentrant"); + assertThat(enableAttempt.get()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("lifecycle"); + assertThat(disableAttempt.get()).isInstanceOf(IllegalStateException.class); + assertThat(registry.stateOf("other")).isEqualTo(Extension.State.DISABLED); + } +} diff --git a/jircd-core/src/test/java/net/jircd/core/extension/ExtensionRegistryTest.java b/jircd-core/src/test/java/net/jircd/core/extension/ExtensionRegistryTest.java index ecb7059..4240655 100644 --- a/jircd-core/src/test/java/net/jircd/core/extension/ExtensionRegistryTest.java +++ b/jircd-core/src/test/java/net/jircd/core/extension/ExtensionRegistryTest.java @@ -34,6 +34,8 @@ import net.jircd.core.session.DisconnectCleanup; import net.jircd.core.session.NicknameRegistry; import net.jircd.core.session.WhowasHistory; +import net.jircd.core.session.command.CommandHandler; +import net.jircd.protocol.Command; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -116,7 +118,23 @@ private ExtensionRegistry newRegistry() { configuration), new DisconnectCleanup( nicknameRegistry, channelRegistry, new WhowasHistory(() -> 100), registry), - (command, handler) -> {})); + new CommandRegistrar() { + @Override + public void register(Command command, CommandHandler handler) { + // Not exercised. + } + + @Override + public boolean isRegistered(Command command) { + return false; + } + + @Override + public void unregister(Command command, CommandHandler installed) { + // Not exercised. + } + }, + registry.capabilityServices())); return registry; } diff --git a/jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityDispatchTest.java b/jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityDispatchTest.java new file mode 100644 index 0000000..6dd9264 --- /dev/null +++ b/jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityDispatchTest.java @@ -0,0 +1,697 @@ +/* + * 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.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.Consumer; +import net.jircd.core.session.ClientSession; +import net.jircd.core.session.OutboundMessage; +import net.jircd.core.session.RateLimitBucket; +import net.jircd.protocol.Command; +import net.jircd.protocol.Message; +import org.junit.jupiter.api.Test; + +class CapabilityDispatchTest { + private record Fault(String provider, HookKind kind, FaultCategory category) {} + + private final List faults = new ArrayList<>(); + private final HookDispatcher dispatcher = + new HookDispatcher( + (provider, kind, category) -> faults.add(new Fault(provider, kind, category))); + + private static RuntimeSnapshot snapshotOf(String id, Consumer staging) { + CapabilityRegistrar registrar = new CapabilityRegistrar(id, id); + staging.accept(registrar); + return RuntimeSnapshot.of(List.of(registrar.build())); + } + + private static ClientSession session(String name) { + ClientSession session = new ClientSession(name, "host", RateLimitBucket.withDefaults()); + session.setNickname(name); + return session; + } + + private static final RequestContext CONTEXT = RequestContext.of(1, Map.of()); + + // ---- events + + private static CommittedEvent event(ClientSession... peers) { + return new CommittedEvent( + EventKind.AWAY_CHANGED, 7, "n!u@h", "", "gone", new Audience(() -> List.of(peers))); + } + + @Test + void eventHookIntentsAreFilteredToNegotiatedAudienceMembers() { + ClientSession listening = session("a"); + listening.replaceNegotiatedCapabilities(Set.of("watcher")); + ClientSession plain = session("b"); + Message notice = Message.of(Command.AWAY, "gone"); + RuntimeSnapshot snapshot = + snapshotOf( + "watcher", + r -> + r.onEvent( + EventKind.AWAY_CHANGED, + e -> List.of(new EventIntent(notice, List.of(listening, plain))))); + List intents = dispatcher.event(snapshot, event(listening, plain)); + assertThat(intents).hasSize(1); + assertThat(intents.getFirst().recipients()).containsExactly(listening); + assertThat(faults).isEmpty(); + } + + @Test + void eventHooksOnlyRunForTheirKindAndNoListenerMeansNoAudienceTraversal() { + int[] traversed = {0}; + Audience audience = + new Audience( + () -> { + traversed[0]++; + return List.of(); + }); + CommittedEvent committed = new CommittedEvent(EventKind.JOINED, 1, "n!u@h", "", "#c", audience); + dispatcher.event(snapshotOf("p", r -> r.onEvent(EventKind.PARTED, e -> List.of())), committed); + dispatcher.event(RuntimeSnapshot.EMPTY, committed); + assertThat(traversed[0]).isZero(); + } + + @Test + void eventIntentsCannotWidenTheAudience() { + ClientSession inside = session("in"); + inside.replaceNegotiatedCapabilities(Set.of("watcher")); + ClientSession outside = session("out"); + outside.replaceNegotiatedCapabilities(Set.of("watcher")); + RuntimeSnapshot snapshot = + snapshotOf( + "watcher", + r -> + r.onEvent( + EventKind.AWAY_CHANGED, + e -> + List.of( + new EventIntent( + Message.of(Command.AWAY, "x"), List.of(inside, outside))))); + assertThat(dispatcher.event(snapshot, event(inside))).isEmpty(); + assertThat(faults) + .containsExactly(new Fault("watcher", HookKind.EVENT, FaultCategory.INVALID_RESULT)); + } + + @Test + void eventAudienceIsComputedOnceAndDeduplicated() { + ClientSession peer = session("a"); + int[] calls = {0}; + Audience audience = + new Audience( + () -> { + calls[0]++; + return List.of(peer, peer); + }); + assertThat(audience.peers()).containsExactly(peer); + assertThat(audience.peers()).containsExactly(peer); + assertThat(calls[0]).isEqualTo(1); + } + + @Test + void throwingNullAndOversizedEventResultsAreContainedAndFallBackToNoDelivery() { + ClientSession peer = session("a"); + peer.replaceNegotiatedCapabilities(Set.of("a", "b", "c")); + RuntimeSnapshot thrown = + snapshotOf( + "a", + r -> + r.onEvent( + EventKind.AWAY_CHANGED, + e -> { + throw new IllegalStateException("secret payload"); + })); + RuntimeSnapshot nulls = snapshotOf("b", r -> r.onEvent(EventKind.AWAY_CHANGED, e -> null)); + RuntimeSnapshot many = + snapshotOf( + "c", + r -> + r.onEvent( + EventKind.AWAY_CHANGED, + e -> + java.util.Collections.nCopies( + HookDispatcher.MAX_EVENT_INTENTS + 1, + new EventIntent(Message.of(Command.AWAY, "x"), List.of(peer))))); + assertThat(dispatcher.event(thrown, event(peer))).isEmpty(); + assertThat(dispatcher.event(nulls, event(peer))).isEmpty(); + assertThat(dispatcher.event(many, event(peer))).isEmpty(); + assertThat(faults) + .extracting(Fault::category) + .containsExactly( + FaultCategory.EXCEPTION, FaultCategory.NULL_RESULT, FaultCategory.INVALID_RESULT); + } + + @Test + void aFaultingProviderDoesNotStopTheOthers() { + ClientSession peer = session("a"); + peer.replaceNegotiatedCapabilities(Set.of("good", "bad")); + CapabilityRegistrar bad = new CapabilityRegistrar("bad", "bad"); + bad.onEvent( + EventKind.AWAY_CHANGED, + e -> { + throw new IllegalStateException(); + }); + CapabilityRegistrar good = new CapabilityRegistrar("good", "good"); + good.onEvent( + EventKind.AWAY_CHANGED, + e -> List.of(new EventIntent(Message.of(Command.AWAY), List.of(peer)))); + RuntimeSnapshot snapshot = RuntimeSnapshot.of(List.of(bad.build(), good.build())); + assertThat(dispatcher.event(snapshot, event(peer))).hasSize(1); + assertThat(faults).extracting(Fault::provider).containsExactly("bad"); + } + + @Test + void linkageErrorsAreContainedButVirtualMachineErrorsPropagate() { + RuntimeSnapshot linkage = + snapshotOf( + "a", + r -> + r.onEvent( + EventKind.AWAY_CHANGED, + e -> { + throw new NoClassDefFoundError("missing"); + })); + assertThat(dispatcher.event(linkage, event())).isEmpty(); + RuntimeSnapshot fatal = + snapshotOf( + "b", + r -> + r.onEvent( + EventKind.AWAY_CHANGED, + e -> { + throw new OutOfMemoryError(); + })); + CommittedEvent committed = event(); + assertThatThrownBy(() -> dispatcher.event(fatal, committed)) + .isInstanceOf(OutOfMemoryError.class); + } + + // ---- inbound tags + + @Test + void inboundTagsSelectOnlyClientTagsPresentInTheInput() { + RuntimeSnapshot snapshot = + snapshotOf( + "mt", + r -> + r.onInboundTags( + (in, ctx) -> + new InboundTagResult( + Map.of("+a", "1", "+forged", "x"), Map.of("mt/seen", "yes")))); + InboundTags input = new InboundTags("PRIVMSG", Map.of("+a", "1")); + InboundTagResult result = dispatcher.inboundTags(snapshot, Set.of("mt"), input, CONTEXT); + assertThat(result).isEqualTo(InboundTagResult.NONE); + assertThat(faults) + .containsExactly(new Fault("mt", HookKind.INBOUND_TAGS, FaultCategory.INVALID_RESULT)); + } + + @Test + void validInboundTagResultIsMergedAndSenderPrerequisitesGateIt() { + CapabilityRegistrar registrar = new CapabilityRegistrar("mt", "mt"); + registrar.onInboundTags( + Set.of("gate"), + (in, ctx) -> new InboundTagResult(Map.of("+a", "1"), Map.of("mt/seen", "yes"))); + RuntimeSnapshot snapshot = RuntimeSnapshot.of(List.of(registrar.build())); + InboundTags input = new InboundTags("PRIVMSG", Map.of("+a", "1", "+b", "2")); + assertThat(dispatcher.inboundTags(snapshot, Set.of("mt"), input, CONTEXT)) + .isEqualTo(InboundTagResult.NONE); + InboundTagResult gated = dispatcher.inboundTags(snapshot, Set.of("gate"), input, CONTEXT); + assertThat(gated.forwarded()).containsExactly(Map.entry("+a", "1")); + assertThat(gated.context()).containsExactly(Map.entry("mt/seen", "yes")); + assertThat(faults).isEmpty(); + } + + @Test + void inboundContextMustBeProviderNamespacedAndBounded() { + RuntimeSnapshot snapshot = + snapshotOf( + "mt", + r -> + r.onInboundTags( + (in, ctx) -> new InboundTagResult(Map.of(), Map.of("other/key", "v")))); + assertThat( + dispatcher.inboundTags( + snapshot, Set.of(), new InboundTags("PRIVMSG", Map.of()), CONTEXT)) + .isEqualTo(InboundTagResult.NONE); + assertThat(faults).hasSize(1); + } + + // ---- recipients + + @Test + void recipientHookAddsOnlySenderCopyWhenSenderNegotiatedTheOwner() { + RuntimeSnapshot snapshot = + snapshotOf("echo", r -> r.onRecipients((intent, ctx) -> Set.of(DeliveryRole.SENDER_COPY))); + DeliveryIntent intent = + new DeliveryIntent(UUID.randomUUID(), Set.of(DeliveryRole.ORDINARY_TARGET)); + assertThat(dispatcher.recipientRoles(snapshot, Set.of(), intent, CONTEXT)).isEmpty(); + assertThat(dispatcher.recipientRoles(snapshot, Set.of("echo"), intent, CONTEXT)) + .containsExactly(DeliveryRole.SENDER_COPY); + } + + @Test + void recipientHookResultOutsideItsPermittedScopeIsAFault() { + RuntimeSnapshot snapshot = + snapshotOf( + "echo", r -> r.onRecipients((intent, ctx) -> Set.of(DeliveryRole.REQUEST_REPLY))); + DeliveryIntent intent = + new DeliveryIntent(UUID.randomUUID(), Set.of(DeliveryRole.ORDINARY_TARGET)); + assertThat(dispatcher.recipientRoles(snapshot, Set.of("echo"), intent, CONTEXT)).isEmpty(); + RuntimeSnapshot nulls = snapshotOf("n", r -> r.onRecipients((i, ctx) -> null)); + assertThat(dispatcher.recipientRoles(nulls, Set.of("n"), intent, CONTEXT)).isEmpty(); + assertThat(faults) + .extracting(Fault::category) + .containsExactly(FaultCategory.INVALID_RESULT, FaultCategory.NULL_RESULT); + } + + // ---- outbound tags + + private static OutboundMessage message() { + return OutboundMessage.now("n!u@h", "PRIVMSG", "#c", "hi"); + } + + private static HookRegistration tagHook(Set keys, long max, Map result) { + CapabilityRegistrar registrar = new CapabilityRegistrar("tagger", "tagger"); + registrar.onOutboundTags(keys, max, m -> result); + return registrar.build().registrations().getFirst(); + } + + @Test + void outboundTagContributionIsValidatedAgainstOwnedKeysAndDeclaredBound() { + assertThat(dispatcher.outboundTags(tagHook(Set.of("a"), 16, Map.of("a", "v")), message())) + .containsExactly(Map.entry("a", "v")); + assertThat(dispatcher.outboundTags(tagHook(Set.of("a"), 16, Map.of()), message())).isEmpty(); + assertThat(faults).isEmpty(); + // forged key, over declared bound, null value, null map + assertThat(dispatcher.outboundTags(tagHook(Set.of("a"), 16, Map.of("b", "v")), message())) + .isEmpty(); + assertThat(dispatcher.outboundTags(tagHook(Set.of("a"), 4, Map.of("a", "toolong")), message())) + .isEmpty(); + Map nullValue = new java.util.HashMap<>(); + nullValue.put("a", null); + assertThat(dispatcher.outboundTags(tagHook(Set.of("a"), 16, nullValue), message())).isEmpty(); + assertThat(dispatcher.outboundTags(tagHook(Set.of("a"), 16, null), message())).isEmpty(); + assertThat(faults) + .extracting(Fault::category) + .containsExactly( + FaultCategory.INVALID_RESULT, + FaultCategory.INVALID_RESULT, + FaultCategory.INVALID_RESULT, + FaultCategory.NULL_RESULT); + } + + @Test + void outboundTagBoundCountsEscapedBytes() { + // "a=\:\s": key 1 + '=' + the value "; " escaped to 4 bytes = 6 data bytes + HookRegistration tight = tagHook(Set.of("a"), 5, Map.of("a", "; ")); + assertThat(dispatcher.outboundTags(tight, message())).isEmpty(); + assertThat(faults).hasSize(1); + HookRegistration roomy = tagHook(Set.of("a"), 6, Map.of("a", "; ")); + assertThat(dispatcher.outboundTags(roomy, message())).containsEntry("a", "; "); + } + + @Test + void outboundTagResultIsImmutableAndDetachedFromTheProvidersMap() { + Map source = new java.util.HashMap<>(Map.of("a", "v")); + HookRegistration hook = tagHook(Set.of("a"), 16, source); + Map result = dispatcher.outboundTags(hook, message()); + source.put("a", "changed"); + assertThat(result).containsEntry("a", "v"); + assertThatThrownBy(() -> result.put("b", "c")) + .isInstanceOf(UnsupportedOperationException.class); + } + + // ---- member status / identity / fields + + private static MemberSnapshot member() { + return new MemberSnapshot("nick", "id", "host", List.of("@", "+")); + } + + @Test + void memberStatusDefaultsToHighestAndHookReplacesWithOrderedHeldSymbols() { + RuntimeSnapshot snapshot = + snapshotOf( + "mp", r -> r.onMemberStatus(limits -> 2, m -> String.join("", m.heldStatuses()))); + assertThat(dispatcher.memberStatus(snapshot, Set.of(), member())).isEqualTo("@"); + assertThat(dispatcher.memberStatus(snapshot, Set.of("mp"), member())).isEqualTo("@+"); + assertThat(dispatcher.memberStatus(RuntimeSnapshot.EMPTY, Set.of("mp"), member())) + .isEqualTo("@"); + assertThat( + dispatcher.memberStatus( + RuntimeSnapshot.EMPTY, Set.of(), new MemberSnapshot("n", "i", "h", List.of()))) + .isEmpty(); + } + + @Test + void memberStatusOutsideHeldOrderedOrBoundedSymbolsIsAFaultAndFallsBack() { + for (String bad : new String[] {"+@", "%", "@@", "@+!"}) { + RuntimeSnapshot snapshot = snapshotOf("mp", r -> r.onMemberStatus(limits -> 2, m -> bad)); + assertThat(dispatcher.memberStatus(snapshot, Set.of("mp"), member())).isEqualTo("@"); + } + RuntimeSnapshot nulls = snapshotOf("n", r -> r.onMemberStatus(limits -> 2, m -> null)); + assertThat(dispatcher.memberStatus(nulls, Set.of("n"), member())).isEqualTo("@"); + RuntimeSnapshot thrown = + snapshotOf( + "t", + r -> + r.onMemberStatus( + limits -> 2, + m -> { + throw new IllegalStateException(); + })); + assertThat(dispatcher.memberStatus(thrown, Set.of("t"), member())).isEqualTo("@"); + assertThat(faults).hasSize(6); + } + + @Test + void memberStatusLongerThanItsDeclaredBoundIsAFault() { + RuntimeSnapshot snapshot = + snapshotOf( + "mp", r -> r.onMemberStatus(limits -> 1, m -> String.join("", m.heldStatuses()))); + assertThat(dispatcher.memberStatus(snapshot, Set.of("mp"), member())).isEqualTo("@"); + assertThat(faults).hasSize(1); + } + + @Test + void namesIdentityDefaultsToNicknameAndRejectsUnsafeOrOversizedTokens() { + RuntimeSnapshot snapshot = + snapshotOf( + "uh", + r -> + r.onNamesIdentity( + limits -> 100, m -> m.nickname() + "!" + m.ident() + "@" + m.displayHost())); + assertThat(dispatcher.namesIdentity(snapshot, Set.of(), member())).isEqualTo("nick"); + assertThat(dispatcher.namesIdentity(snapshot, Set.of("uh"), member())) + .isEqualTo("nick!id@host"); + for (String bad : new String[] {"", "has space", "x".repeat(200), "line\r\nbreak"}) { + RuntimeSnapshot broken = snapshotOf("b", r -> r.onNamesIdentity(limits -> 100, m -> bad)); + assertThat(dispatcher.namesIdentity(broken, Set.of("b"), member())).isEqualTo("nick"); + } + assertThat(faults).hasSize(4); + } + + @Test + void replyFieldContributionIsBoundedAndOptional() { + ReplySlot slot = new ReplySlot("extra"); + CapabilityRegistrar registrar = new CapabilityRegistrar("f", "f", Set.of(slot)); + registrar.onReplyField(slot, limits -> 3, m -> m.nickname().length() > 3 ? "toolong" : "ok"); + RuntimeSnapshot snapshot = RuntimeSnapshot.of(List.of(registrar.build())); + assertThat( + dispatcher.replyField( + snapshot, Set.of("f"), slot, new MemberSnapshot("ab", "i", "h", List.of()))) + .contains("ok"); + assertThat(dispatcher.replyField(snapshot, Set.of(), slot, member())).isEmpty(); + assertThat(dispatcher.replyField(snapshot, Set.of("f"), slot, member())).isEmpty(); + assertThat(faults).hasSize(1); + } + + // ---- output groups + + private static OutputGroup group() { + return new OutputGroup( + "g1", "test", List.of(), List.of(Message.of(Command.PRIVMSG, "#c", "hi")), 2048); + } + + @Test + void outputGroupFallsBackToPlainMembersWithoutOwnerOrPrerequisites() { + Message open = new Message(Map.of(), null, null, "BATCH", List.of("+r", "test")); + Message close = new Message(Map.of(), null, null, "BATCH", List.of("-r")); + CapabilityRegistrar registrar = new CapabilityRegistrar("b", "b"); + registrar.onOutputGroup( + Set.of("mt"), (g, ctx) -> new FramingPlan(List.of(open, g.members().getFirst(), close))); + RuntimeSnapshot snapshot = RuntimeSnapshot.of(List.of(registrar.build())); + FramingPlan plain = FramingPlan.plain(group()); + assertThat(dispatcher.outputGroup(snapshot, Set.of(), group(), CONTEXT).frames()) + .isEqualTo(plain.frames()); + assertThat(dispatcher.outputGroup(snapshot, Set.of("b"), group(), CONTEXT).frames()) + .isEqualTo(plain.frames()); + assertThat(dispatcher.outputGroup(snapshot, Set.of("b", "mt"), group(), CONTEXT).frames()) + .hasSize(3); + assertThat(faults).isEmpty(); + } + + @Test + void illegalFramingPlansAreFaultsAndFallBackToPlainMembers() { + OutputGroup group = group(); + Message tooBig = Message.of(Command.TOPIC, "#c", "x".repeat(4000)); + FramingPlan[] bad = { + new FramingPlan(List.of()), + new FramingPlan(List.of(tooBig)), + new FramingPlan( + java.util.Collections.nCopies( + HookDispatcher.MAX_FRAMING_OVERHEAD_FRAMES + 2, group.members().getFirst())), + }; + for (FramingPlan plan : bad) { + RuntimeSnapshot snapshot = snapshotOf("b", r -> r.onOutputGroup(Set.of(), (g, ctx) -> plan)); + assertThat(dispatcher.outputGroup(snapshot, Set.of("b"), group, CONTEXT).frames()) + .isEqualTo(group.members()); + } + RuntimeSnapshot nulls = snapshotOf("n", r -> r.onOutputGroup(Set.of(), (g, ctx) -> null)); + assertThat(dispatcher.outputGroup(nulls, Set.of("n"), group, CONTEXT).frames()) + .isEqualTo(group.members()); + assertThat(faults).hasSize(4); + } + + @Test + void outputGroupByteBoundIsEnforcedBeforeAcceptingThePlan() { + OutputGroup tight = + new OutputGroup("g", "t", List.of(), List.of(Message.of(Command.PRIVMSG, "#c", "hi")), 10); + RuntimeSnapshot snapshot = + snapshotOf("b", r -> r.onOutputGroup(Set.of(), (g, ctx) -> new FramingPlan(g.members()))); + assertThat(dispatcher.outputGroup(snapshot, Set.of("b"), tight, CONTEXT).frames()) + .isEqualTo(tight.members()); + assertThat(faults).hasSize(1); + } + + // ---- commands + + @Test + void commandHandlerExceptionsAreContained() { + int[] ran = {0}; + CapabilityRegistrar registrar = new CapabilityRegistrar("cmd", "cmd"); + registrar.command( + Command.TAGMSG, + (session, message) -> { + ran[0]++; + throw new IllegalStateException("bad"); + }); + HookRegistration hook = registrar.build().registrations().getFirst(); + var guarded = dispatcher.guardedCommand(hook); + guarded.handle(session("a"), Message.of(Command.TAGMSG, "#c")); + assertThat(ran[0]).isEqualTo(1); + assertThat(faults).containsExactly(new Fault("cmd", HookKind.COMMAND, FaultCategory.EXCEPTION)); + } + + // ---- locks + + @Test + void noHookRunsWhileTheCallerHoldsTheDispatcherOrSnapshotMonitor() { + boolean[] heldDuringCallback = {false}; + RuntimeSnapshot[] holder = new RuntimeSnapshot[1]; + holder[0] = + snapshotOf( + "p", + r -> + r.onEvent( + EventKind.AWAY_CHANGED, + e -> { + heldDuringCallback[0] = + Thread.holdsLock(dispatcher) || Thread.holdsLock(holder[0]); + return List.of(); + })); + dispatcher.event(holder[0], event()); + assertThat(heldDuringCallback[0]).isFalse(); + } + + // ---- group plans must cover the members + + private static Message batch(String param) { + return new Message(Map.of(), null, null, "BATCH", List.of(param, "test")); + } + + @Test + void groupPlansMustCoverEveryMemberInOrderWithMatchedFraming() { + Message m1 = Message.of(Command.PRIVMSG, "#c", "one"); + Message m2 = Message.of(Command.PRIVMSG, "#c", "two"); + OutputGroup two = new OutputGroup("g", "test", List.of(), List.of(m1, m2), 4096); + Message tagged1 = + new Message(Map.of("batch", "r"), null, Command.PRIVMSG, "PRIVMSG", m1.params()); + Message tagged2 = + new Message(Map.of("batch", "r"), null, Command.PRIVMSG, "PRIVMSG", m2.params()); + FramingPlan good = new FramingPlan(List.of(batch("+r"), tagged1, tagged2, batch("-r"))); + FramingPlan[] bad = { + new FramingPlan(List.of(batch("+r"))), // opener only, members lost + new FramingPlan(List.of(batch("+r"), tagged1, tagged2)), // never closed + new FramingPlan(List.of(tagged1, tagged2, batch("-r"))), // close without open + new FramingPlan(List.of(batch("+r"), tagged2, tagged1, batch("-r"))), // reordered + new FramingPlan(List.of(batch("+r"), tagged1, batch("-r"))), // member dropped + new FramingPlan(List.of(batch("+r"), tagged1, batch("-r"), tagged2)), // member outside + new FramingPlan(List.of(tagged2, tagged1, tagged2, tagged1)), // members posing as framing + new FramingPlan(List.of(batch("+r"), tagged1, tagged2, batch("+s"))), // never closed + new FramingPlan(List.of(batch("+r"), tagged1, tagged2, batch("-s"))), // wrong reference + }; + RuntimeSnapshot ok = snapshotOf("b", r -> r.onOutputGroup(Set.of(), (g, ctx) -> good)); + assertThat(dispatcher.outputGroup(ok, Set.of("b"), two, CONTEXT).frames()).hasSize(4); + assertThat(faults).isEmpty(); + for (FramingPlan plan : bad) { + RuntimeSnapshot snapshot = snapshotOf("b", r -> r.onOutputGroup(Set.of(), (g, ctx) -> plan)); + assertThat(dispatcher.outputGroup(snapshot, Set.of("b"), two, CONTEXT).frames()) + .isEqualTo(two.members()); + } + assertThat(faults).hasSize(bad.length); + } + + // ---- bounds follow the configured limits + + @Test + void replyBoundsAreEvaluatedAgainstTheConfiguredLimitsNotTheReferenceOnes() { + RuntimeSnapshot reference = + snapshotOf( + "uh", + r -> r.onNamesIdentity(ReplyLimits::nicknameMax, m -> m.nickname() + "x".repeat(20))); + RuntimeSnapshot configured = reference.withLimits(ReplyLimits.of(16, 9, 50)); + assertThat(dispatcher.namesIdentity(reference, Set.of("uh"), member())) + .isEqualTo("nick" + "x".repeat(20)); + assertThat(dispatcher.namesIdentity(configured, Set.of("uh"), member())).isEqualTo("nick"); + assertThat(faults).hasSize(1); + RuntimeSnapshot fits = + snapshotOf( + "fit", + r -> r.onNamesIdentity(ReplyLimits::nicknameMax, m -> m.nickname() + "xxxxx")) + .withLimits(ReplyLimits.of(16, 9, 50)); + assertThat(dispatcher.namesIdentity(fits, Set.of("fit"), member())).isEqualTo("nickxxxxx"); + } + + @Test + void aServerTagValueCarryingNulIsAFault() { + assertThat(dispatcher.outboundTags(tagHook(Set.of("a"), 16, Map.of("a", "x\0y")), message())) + .isEmpty(); + assertThat(faults).hasSize(1); + } + + // ---- results are copied before they are validated + + private static final class ShiftingMap extends java.util.AbstractMap { + int iterations; + + @Override + public Set> entrySet() { + return new java.util.AbstractSet<>() { + @Override + public java.util.Iterator> iterator() { + iterations++; + return Map.of(iterations == 1 ? "a" : "stolen", "v").entrySet().iterator(); + } + + @Override + public int size() { + return 1; + } + }; + } + } + + private static final class OneShotRoles extends java.util.AbstractSet { + int reads; + + @Override + public java.util.Iterator iterator() { + reads++; + if (reads > 1) throw new java.util.ConcurrentModificationException(); + return Set.of(DeliveryRole.SENDER_COPY).iterator(); + } + + @Override + public int size() { + return 1; + } + } + + @Test + void aTagResultThatChangesAfterTheFirstReadIsValidatedAndUsedAsOneCopy() { + Map tags = + dispatcher.outboundTags(tagHook(Set.of("a"), 16, new ShiftingMap()), message()); + assertThat(tags).containsExactly(Map.entry("a", "v")); + assertThat(faults).isEmpty(); + } + + @Test + void aRecipientRoleSetThatCannotBeReadTwiceStillYieldsItsValidatedCopy() { + RuntimeSnapshot snapshot = + snapshotOf("echo", r -> r.onRecipients((i, c) -> new OneShotRoles())); + DeliveryIntent intent = + new DeliveryIntent(UUID.randomUUID(), Set.of(DeliveryRole.ORDINARY_TARGET)); + assertThat(dispatcher.recipientRoles(snapshot, Set.of("echo"), intent, CONTEXT)) + .containsExactly(DeliveryRole.SENDER_COPY); + assertThat(faults).isEmpty(); + } + + @Test + void anEventIntentListThatCannotBeReadTwiceStillYieldsItsValidatedCopy() { + ClientSession listening = session("a"); + listening.replaceNegotiatedCapabilities(Set.of("watcher")); + int[] reads = {0}; + List once = + new java.util.AbstractList<>() { + @Override + public EventIntent get(int index) { + reads[0]++; + if (reads[0] > 1) throw new java.util.ConcurrentModificationException(); + return new EventIntent(Message.of(Command.AWAY, "gone"), List.of(listening)); + } + + @Override + public int size() { + return 1; + } + }; + RuntimeSnapshot snapshot = + snapshotOf("watcher", r -> r.onEvent(EventKind.AWAY_CHANGED, e -> once)); + assertThat(dispatcher.event(snapshot, event(listening))).hasSize(1); + assertThat(faults).isEmpty(); + } + + // ---- the temporary legacy tag path is contained like a hook + + @Test + void aLegacyContributionThatThrowsIsNullOrOversizedIsDroppedAndReported() { + assertThat( + dispatcher.legacyOutboundTags( + "old", + () -> { + throw new IllegalStateException("boom"); + })) + .isEmpty(); + assertThat(dispatcher.legacyOutboundTags("old", () -> null)).isEmpty(); + assertThat(dispatcher.legacyOutboundTags("old", () -> Map.of("a", "x".repeat(4100)))).isEmpty(); + assertThat(dispatcher.legacyOutboundTags("old", () -> Map.of("a", "v"))) + .containsExactly(Map.entry("a", "v")); + assertThat(faults) + .containsExactly( + new Fault("old", HookKind.OUTBOUND_TAGS, FaultCategory.EXCEPTION), + new Fault("old", HookKind.OUTBOUND_TAGS, FaultCategory.NULL_RESULT), + new Fault("old", HookKind.OUTBOUND_TAGS, FaultCategory.INVALID_RESULT)); + } +} 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 new file mode 100644 index 0000000..d4ba51e --- /dev/null +++ b/jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityRegistrarTest.java @@ -0,0 +1,292 @@ +/* + * 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.util.List; +import java.util.Map; +import java.util.Set; +import net.jircd.protocol.Command; +import org.junit.jupiter.api.Test; + +class CapabilityRegistrarTest { + + private static CapabilityRegistrar registrar(String id) { + return new CapabilityRegistrar(id, id); + } + + private static CapabilityDescriptor withTagHook(String id, String key) { + CapabilityRegistrar registrar = registrar(id); + registrar.onOutboundTags(Set.of(key), 64, message -> Map.of(key, "v")); + return registrar.build(); + } + + @Test + void rejectsEmptyOrUnsafeIdsAndOffers() { + assertThatThrownBy(() -> new CapabilityRegistrar("", "x")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new CapabilityRegistrar("a", "has space")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new CapabilityRegistrar("a", "x=y")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new CapabilityRegistrar(null, "x")) + .isInstanceOf(IllegalArgumentException.class); + String longName = "x".repeat(65); + assertThatThrownBy(() -> new CapabilityRegistrar("a", longName)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void reservesCoreCapNotify() { + assertThatThrownBy(() -> new CapabilityRegistrar("cap-notify", "cap-notify")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cap-notify"); + assertThatThrownBy(() -> new CapabilityRegistrar("mine", "cap-notify")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void snapshotRejectsDuplicateProviderIdsAndOffers() { + CapabilityDescriptor first = withTagHook("alpha", "a"); + CapabilityRegistrar sameOffer = new CapabilityRegistrar("beta", "alpha"); + List descriptors1 = List.of(first, first); + assertThatThrownBy(() -> RuntimeSnapshot.of(descriptors1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("alpha"); + CapabilityDescriptor duplicateOffer = sameOffer.build(); + List descriptors2 = List.of(first, duplicateOffer); + assertThatThrownBy(() -> RuntimeSnapshot.of(descriptors2)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("alpha"); + } + + @Test + void descriptorIsImmutableAndStagedRegistrationsAreFrozenAfterBuild() { + CapabilityRegistrar registrar = registrar("alpha"); + registrar.onOutboundTags(Set.of("a"), 64, message -> Map.of("a", "v")); + CapabilityDescriptor descriptor = registrar.build(); + List registrations = descriptor.registrations(); + HookRegistration first = registrations.getFirst(); + assertThatThrownBy(() -> registrations.add(first)) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> registrar.onEvent(EventKind.JOINED, event -> List.of())) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(registrar::build).isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> registrar.requiresProviders("other")) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void hardDependenciesRejectSelfDuplicateUnknownAndCyclicDeclarations() { + CapabilityRegistrar self = registrar("alpha"); + assertThatThrownBy(() -> self.requiresProviders("alpha")) + .isInstanceOf(IllegalArgumentException.class); + CapabilityRegistrar duplicate = registrar("alpha"); + assertThatThrownBy(() -> duplicate.requiresProviders("beta", "beta")) + .isInstanceOf(IllegalArgumentException.class); + + CapabilityRegistrar needsMissing = registrar("alpha"); + needsMissing.requiresProviders("missing"); + CapabilityDescriptor unknown = needsMissing.build(); + List descriptors3 = List.of(unknown); + assertThatThrownBy(() -> RuntimeSnapshot.of(descriptors3)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("missing"); + + CapabilityRegistrar a = registrar("a"); + a.requiresProviders("b"); + CapabilityRegistrar b = registrar("b"); + b.requiresProviders("a"); + CapabilityDescriptor da = a.build(); + CapabilityDescriptor db = b.build(); + List descriptors4 = List.of(da, db); + assertThatThrownBy(() -> RuntimeSnapshot.of(descriptors4)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cyclic"); + assertThat(da.requiresProviders()).containsExactly("b"); + } + + @Test + void rejectsServerTagKeyConflictsBetweenProviders() { + CapabilityDescriptor first = withTagHook("alpha", "shared"); + CapabilityDescriptor second = withTagHook("beta", "shared"); + List descriptors5 = List.of(first, second); + assertThatThrownBy(() -> RuntimeSnapshot.of(descriptors5)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("shared"); + } + + @Test + void rejectsReplacementSlotConflictsBetweenProviders() { + CapabilityRegistrar first = registrar("alpha"); + first.onMemberStatus(limits -> 2, member -> ""); + CapabilityRegistrar second = registrar("beta"); + second.onMemberStatus(limits -> 2, member -> ""); + CapabilityDescriptor a = first.build(); + CapabilityDescriptor b = second.build(); + List descriptors6 = List.of(a, b); + assertThatThrownBy(() -> RuntimeSnapshot.of(descriptors6)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("STATUS"); + } + + @Test + void rejectsSlotConflictInsideOneProvider() { + CapabilityRegistrar registrar = registrar("alpha"); + registrar.onMemberStatus(limits -> 2, member -> ""); + assertThatThrownBy(() -> registrar.onMemberStatus(limits -> 2, member -> "")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsTagContributionRegistrationsThatCannotBeBounded() { + CapabilityRegistrar registrar = registrar("alpha"); + Set one = Set.of("a"); + Hooks.OutboundTagHook none = m -> Map.of(); + assertThatThrownBy(() -> registrar.onOutboundTags(one, 0, none)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> registrar.onOutboundTags(one, 4095, none)) + .isInstanceOf(IllegalArgumentException.class); + Set empty = Set.of(); + assertThatThrownBy(() -> registrar.onOutboundTags(empty, 8, none)) + .isInstanceOf(IllegalArgumentException.class); + Set clientOnly = Set.of("+client"); + assertThatThrownBy(() -> registrar.onOutboundTags(clientOnly, 8, none)) + .isInstanceOf(IllegalArgumentException.class); + Set batch = Set.of("batch"); + assertThatThrownBy(() -> registrar.onOutboundTags(batch, 8, none)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("batch"); + assertThatThrownBy(() -> registrar.onOutboundTags(one, 8, null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void rejectsReplyBoundsThatAreNullNegativeOrExceedTheCommandLimit() { + CapabilityRegistrar registrar = registrar("alpha"); + assertThatThrownBy(() -> registrar.onMemberStatus(null, m -> "")) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> registrar.onMemberStatus(limits -> -1, m -> "")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> registrar.onNamesIdentity(limits -> 513, m -> "x")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + registrar.onNamesIdentity( + limits -> { + throw new IllegalStateException("boom"); + }, + m -> "x")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void replyFieldSlotsMustBeDeclaredBySchema() { + ReplySlot declared = new ReplySlot("extra"); + CapabilityRegistrar registrar = new CapabilityRegistrar("alpha", "alpha", Set.of(declared)); + ReplySlot other = new ReplySlot("other"); + assertThatThrownBy(() -> registrar.onReplyField(other, limits -> 1, member -> "x")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("other"); + registrar.onReplyField(declared, limits -> 1, member -> "x"); + assertThat(registrar.build().registrations()).hasSize(1); + } + + @Test + void rejectsMoreRegistrationsAndPrerequisitesThanTheMetadataBounds() { + CapabilityRegistrar registrar = registrar("alpha"); + for (int i = 0; i < CapabilityRegistrar.MAX_REGISTRATIONS; i++) { + registrar.onEvent(EventKind.JOINED, event -> List.of()); + } + assertThatThrownBy(() -> registrar.onEvent(EventKind.PARTED, event -> List.of())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("registrations"); + CapabilityRegistrar deps = registrar("beta"); + for (int i = 0; i < CapabilityRegistrar.MAX_REQUIRED_PROVIDERS; i++) { + deps.requiresProviders("dep" + i); + } + assertThatThrownBy(() -> deps.requiresProviders("one-more")) + .isInstanceOf(IllegalStateException.class); + CapabilityRegistrar gates = registrar("gamma"); + Set tooMany = new java.util.HashSet<>(); + for (int i = 0; i <= CapabilityRegistrar.MAX_PREREQUISITES; i++) tooMany.add("g" + i); + assertThatThrownBy(() -> gates.onOutputGroup(tooMany, (group, ctx) -> null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void closingRegistrationsAndRegistrarIsIdempotentAndRollsBackStaging() { + CapabilityRegistrar registrar = registrar("alpha"); + CapabilityDescriptor descriptor; + try (Registration kept = registrar.onEvent(EventKind.JOINED, event -> List.of()); + Registration dropped = registrar.onEvent(EventKind.PARTED, event -> List.of())) { + dropped.close(); + dropped.close(); + CapabilityRegistrar second = registrar("beta"); + second.onEvent(EventKind.JOINED, event -> List.of()); + second.close(); + second.close(); + assertThatThrownBy(second::build).isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> second.onEvent(EventKind.JOINED, event -> List.of())) + .isInstanceOf(IllegalStateException.class); + descriptor = registrar.build(); + assertThat(descriptor.registrations()).hasSize(1); + kept.close(); + } + // Closing handles after publication never changes the frozen descriptor. + assertThat(descriptor.registrations()).hasSize(1); + } + + @Test + void commandRegistrationsAreOwnedByTheProviderAndConflictsAreRejected() { + CapabilityRegistrar first = registrar("alpha"); + first.command(Command.TAGMSG, (session, message) -> {}); + CapabilityRegistrar second = registrar("beta"); + second.command(Command.TAGMSG, (session, message) -> {}); + CapabilityDescriptor a = first.build(); + CapabilityDescriptor b = second.build(); + List descriptors7 = List.of(a, b); + assertThatThrownBy(() -> RuntimeSnapshot.of(descriptors7)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("TAGMSG"); + } + + @Test + void snapshotIndexesHooksDeterministicallyByProviderId() { + CapabilityDescriptor b = withTagHook("beta", "b"); + CapabilityDescriptor a = withTagHook("alpha", "a"); + RuntimeSnapshot snapshot = RuntimeSnapshot.of(List.of(b, a)); + assertThat(snapshot.hooks(HookKind.OUTBOUND_TAGS)) + .extracting(HookRegistration::providerId) + .containsExactly("alpha", "beta"); + assertThat(snapshot.without("alpha").hooks(HookKind.OUTBOUND_TAGS)).hasSize(1); + assertThat(RuntimeSnapshot.EMPTY.hooks(HookKind.EVENT)).isEmpty(); + } + + @Test + void aRejectedRequiresProvidersCallStagesNoneOfItsIds() { + CapabilityRegistrar registrar = registrar("me"); + assertThatThrownBy(() -> registrar.requiresProviders("fine", "fine")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> registrar.requiresProviders("fine", "me")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> registrar.requiresProviders("fine", "bad id")) + .isInstanceOf(IllegalArgumentException.class); + assertThat(registrar.build().requiresProviders()).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 4b05ac8..bdc95a9 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 @@ -92,20 +92,20 @@ void oversizedClientDataIsRejectedAfterEscaping() { } @Test - void oversizedServerDataIsRejectedEvenWithNoClientTags() { + void anOversizedLegacyServerContributionIsOmittedAtPreparationNotTheMessage() { OutboundMessage message = OutboundMessage.now("sender!ident@host", "PRIVMSG", "bob", "hello"); - assertThatThrownBy(() -> renderer(Map.of("s", "x".repeat(4093))).render(recipient(), message)) - .isInstanceOf(MessageSerializer.MessageTooLongException.class); + assertThat(renderer(Map.of("s", "x".repeat(4093))).render(recipient(), message)).isEmpty(); } @Test - void rejectedRenderedTagsDoNotStopSubsequentWrites() { + void anOversizedLegacyContributionDoesNotDropTheMessageOrStopSubsequentWrites() { ByteArrayOutputStream out = new ByteArrayOutputStream(); try (SessionWriter writer = new SessionWriter(recipient(), out, renderer(Map.of("s", "x".repeat(4093))))) { writer.enqueue(OutboundMessage.now("sender!ident@host", "PRIVMSG", "bob", "hello")); writer.enqueueRaw(Message.of(Command.PING, "alive")); } - assertThat(out.toString(StandardCharsets.UTF_8)).isEqualTo("PING alive\r\n"); + assertThat(out.toString(StandardCharsets.UTF_8)) + .isEqualTo(":sender!ident@host PRIVMSG bob hello\r\nPING alive\r\n"); } } diff --git a/jircd-core/src/test/java/net/jircd/core/session/CapabilityTagRendererMigrationTest.java b/jircd-core/src/test/java/net/jircd/core/session/CapabilityTagRendererMigrationTest.java new file mode 100644 index 0000000..fde9a73 --- /dev/null +++ b/jircd-core/src/test/java/net/jircd/core/session/CapabilityTagRendererMigrationTest.java @@ -0,0 +1,335 @@ +/* + * 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 java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import net.jircd.core.capability.Capability; +import net.jircd.core.extension.CapabilityExtension; +import net.jircd.core.extension.ExtensionRegistry; +import net.jircd.core.extension.ServerContext; +import net.jircd.core.extension.capability.CapabilityRegistrar; +import org.junit.jupiter.api.Test; + +/** + * Migrated providers publish typed tag hooks and are never also asked through the legacy method. + */ +class CapabilityTagRendererMigrationTest { + + private static class Provider implements CapabilityExtension { + final AtomicInteger hookCalls = new AtomicInteger(); + final AtomicInteger legacyCalls = new AtomicInteger(); + final boolean migrated; + final boolean failing; + + Provider(boolean migrated, boolean failing) { + this.migrated = migrated; + this.failing = failing; + } + + @Override + public String id() { + return "tagger"; + } + + @Override + public void start(ServerContext context) { + // No resources. + } + + @Override + public void stop() { + // No resources. + } + + @Override + public Capability providedCapability() { + return new Capability("tagger"); + } + + @Override + public void register(CapabilityRegistrar registrar) { + if (!migrated) return; + registrar.onOutboundTags( + Set.of("t"), + 16, + message -> { + hookCalls.incrementAndGet(); + if (failing) throw new IllegalStateException("boom"); + return Map.of("t", "hook"); + }); + } + + @Override + public Map contributeTags(OutboundMessage message) { + legacyCalls.incrementAndGet(); + return Map.of("t", "legacy"); + } + } + + private static ClientSession recipient(String name) { + ClientSession session = new ClientSession(name, "host", RateLimitBucket.withDefaults()); + session.replaceNegotiatedCapabilities(List.of("tagger")); + return session; + } + + private static ExtensionRegistry registryWith(Provider provider) { + ExtensionRegistry registry = new ExtensionRegistry(); + registry.register(provider); + registry.enable("tagger"); + return registry; + } + + @Test + void migratedProviderIsEvaluatedOncePerEnvelopeAndNeverThroughTheLegacyMethod() { + Provider provider = new Provider(true, false); + CapabilityTagRenderer renderer = new CapabilityTagRenderer(registryWith(provider)); + OutboundMessage envelope = OutboundMessage.now("n!u@h", "PRIVMSG", "#c", "hi"); + for (int i = 0; i < 5; i++) { + TagRenderer.PreparedTags prepared = renderer.prepare(recipient("r" + i), envelope); + // Drain-time rendering reads the shared immutable result; it calls no provider code. + assertThat(prepared.tags().get()).containsExactly(Map.entry("t", "hook")); + } + assertThat(provider.hookCalls).hasValue(1); + assertThat(provider.legacyCalls).hasValue(0); + } + + @Test + void unmigratedProviderKeepsTheLegacyPath() { + Provider provider = new Provider(false, false); + CapabilityTagRenderer renderer = new CapabilityTagRenderer(registryWith(provider)); + OutboundMessage envelope = OutboundMessage.now("n!u@h", "PRIVMSG", "#c", "hi"); + assertThat(renderer.render(recipient("r"), envelope)).containsExactly(Map.entry("t", "legacy")); + assertThat(provider.hookCalls).hasValue(0); + } + + @Test + void aFaultingHookContributesNothingAndLeavesOtherOutputIntact() { + Provider provider = new Provider(true, true); + CapabilityTagRenderer renderer = new CapabilityTagRenderer(registryWith(provider)); + OutboundMessage envelope = + OutboundMessage.now("n!u@h", "PRIVMSG", "#c", "hi", Map.of("+keep", "me")); + ClientSession session = recipient("r"); + session.replaceNegotiatedCapabilities(List.of("tagger", "message-tags")); + assertThat(renderer.render(session, envelope)).containsExactly(Map.entry("+keep", "me")); + assertThat(provider.legacyCalls).hasValue(0); + } + + @Test + void disablingWithdrawsNewCapturesButCapturedWorkKeepsItsTags() { + Provider provider = new Provider(true, false); + ExtensionRegistry registry = registryWith(provider); + CapabilityTagRenderer renderer = new CapabilityTagRenderer(registry); + OutboundMessage envelope = OutboundMessage.now("n!u@h", "PRIVMSG", "#c", "hi"); + TagRenderer.PreparedTags captured = renderer.prepare(recipient("a"), envelope); + registry.disable("tagger"); + assertThat(captured.tags().get()).containsExactly(Map.entry("t", "hook")); + assertThat(renderer.render(recipient("b"), OutboundMessage.now("n!u@h", "PRIVMSG", "#c", "x"))) + .isEmpty(); + } + + @Test + void hooksThatAreIndividuallyValidButOverflowTogetherLoseTheLaterOneNotTheMessage() { + ExtensionRegistry registry = new ExtensionRegistry(); + registry.register(bulky("aaa", "a")); + registry.register(bulky("bbb", "b")); + registry.enable("aaa"); + registry.enable("bbb"); + ClientSession session = new ClientSession("r", "host", RateLimitBucket.withDefaults()); + session.replaceNegotiatedCapabilities(List.of("aaa", "bbb")); + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + try (SessionWriter writer = + new SessionWriter(session, out, new CapabilityTagRenderer(registry), 1 << 20)) { + writer.enqueue(OutboundMessage.now("n!u@h", "PRIVMSG", "#c", "kept")); + } + String wire = out.toString(java.nio.charset.StandardCharsets.UTF_8); + assertThat(wire) + .endsWith(" :n!u@h PRIVMSG #c kept\r\n") + .contains("a=") + .doesNotContain(";b=") + .doesNotContain("@b="); + } + + private static CapabilityExtension bulky(String id, String key) { + return new Provider(true, false) { + @Override + public String id() { + return id; + } + + @Override + public Capability providedCapability() { + return new Capability(id); + } + + @Override + public void register(CapabilityRegistrar registrar) { + registrar.onOutboundTags(Set.of(key), 4000, message -> Map.of(key, "x".repeat(3000))); + } + }; + } + + @Test + void aTypedContributionThatOverflowsWithALegacyOneIsDroppedNotTheMessage() { + ExtensionRegistry registry = new ExtensionRegistry(); + registry.register( + new Provider(false, false) { + @Override + public String id() { + return "legacy"; + } + + @Override + public Capability providedCapability() { + return new Capability("legacy"); + } + + @Override + public Map contributeTags(OutboundMessage message) { + return Map.of("msgid", "m".repeat(2000)); + } + }); + registry.register(bulky("typed", "t")); + registry.enable("legacy"); + registry.enable("typed"); + ClientSession session = new ClientSession("r", "host", RateLimitBucket.withDefaults()); + session.replaceNegotiatedCapabilities(List.of("legacy", "typed")); + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + try (SessionWriter writer = + new SessionWriter(session, out, new CapabilityTagRenderer(registry), 1 << 20)) { + writer.enqueue(OutboundMessage.now("n!u@h", "PRIVMSG", "#c", "kept")); + } + assertThat(out.toString(java.nio.charset.StandardCharsets.UTF_8)) + .endsWith(" :n!u@h PRIVMSG #c kept\r\n") + .contains("msgid=") + .doesNotContain("t=x"); + } + + private static Provider legacy(String id, java.util.function.Supplier> tags) { + return new Provider(false, false) { + @Override + public String id() { + return id; + } + + @Override + public Capability providedCapability() { + return new Capability(id); + } + + @Override + public Map contributeTags(OutboundMessage message) { + return tags.get(); + } + }; + } + + private static String wireOf(ExtensionRegistry registry, List negotiated) { + ClientSession session = new ClientSession("r", "host", RateLimitBucket.withDefaults()); + session.replaceNegotiatedCapabilities(negotiated); + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + try (SessionWriter writer = + new SessionWriter(session, out, new CapabilityTagRenderer(registry), 1 << 20)) { + writer.enqueue(OutboundMessage.now("n!u@h", "PRIVMSG", "#c", "kept")); + } + return out.toString(java.nio.charset.StandardCharsets.UTF_8); + } + + @Test + void aLegacyProviderThatThrowsOrReturnsNullContributesNothingAndTheMessageStillGoesOut() { + ExtensionRegistry registry = new ExtensionRegistry(); + registry.register( + legacy( + "boom", + () -> { + throw new IllegalStateException("boom"); + })); + registry.register(legacy("nul", () -> null)); + registry.register(legacy("fine", () -> Map.of("msgid", "ok"))); + registry.enable("boom"); + registry.enable("nul"); + registry.enable("fine"); + assertThat(wireOf(registry, List.of("boom", "nul", "fine"))) + .isEqualTo("@msgid=ok :n!u@h PRIVMSG #c kept\r\n"); + } + + @Test + void oversizedLegacyTagsAreOmittedAtEnqueueNotAtDrainSoTheMessageIsNotDropped() { + ExtensionRegistry registry = new ExtensionRegistry(); + registry.register(legacy("huge", () -> Map.of("msgid", "m".repeat(4100)))); + registry.register(legacy("one", () -> Map.of("a", "x".repeat(2500)))); + registry.register(legacy("two", () -> Map.of("b", "y".repeat(2500)))); + registry.enable("huge"); + registry.enable("one"); + registry.enable("two"); + String wire = wireOf(registry, List.of("huge", "one", "two")); + assertThat(wire).endsWith(" :n!u@h PRIVMSG #c kept\r\n").doesNotContain("msgid="); + // Together they overflow one origin: exactly one of the two survives, whichever came first. + assertThat(wire.contains("a=") ^ wire.contains("b=")).isTrue(); + } + + @Test + void legacySelectionFollowsTheCapturedSnapshotNotTheLiveEnabledState() throws Exception { + java.util.concurrent.CountDownLatch stopping = new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.CountDownLatch release = new java.util.concurrent.CountDownLatch(1); + Provider slow = + new Provider(false, false) { + @Override + public String id() { + return "slow"; + } + + @Override + public Capability providedCapability() { + return new Capability("slow"); + } + + @Override + public Map contributeTags(OutboundMessage message) { + return Map.of("t", "legacy"); + } + + @Override + public void stop() { + stopping.countDown(); + try { + release.await(); + } catch (InterruptedException _) { + Thread.currentThread().interrupt(); + } + } + }; + ExtensionRegistry registry = new ExtensionRegistry(); + registry.register(slow); + registry.enable("slow"); + Thread disabler = new Thread(() -> registry.disable("slow")); + disabler.start(); + assertThat(stopping.await(10, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + ClientSession session = new ClientSession("r", "host", RateLimitBucket.withDefaults()); + session.replaceNegotiatedCapabilities(List.of("slow")); + // Withdrawn from the published view: no tags, even while stop() has not returned. + assertThat( + new CapabilityTagRenderer(registry) + .render(session, OutboundMessage.now("n!u@h", "PRIVMSG", "#c", "x"))) + .isEmpty(); + release.countDown(); + disabler.join(10_000); + } +} 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 new file mode 100644 index 0000000..2e3bf79 --- /dev/null +++ b/jircd-core/src/test/java/net/jircd/core/session/ConnectionHandlerCommandTableTest.java @@ -0,0 +1,94 @@ +/* + * 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 net.jircd.core.config.ServerConfiguration; +import net.jircd.core.extension.CommandRegistrar; +import net.jircd.core.extension.ExtensionRegistry; +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. */ +class ConnectionHandlerCommandTableTest { + + private static ConnectionHandler handler() { + ExtensionRegistry extensions = new ExtensionRegistry(); + return 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)); + } + + @Test + void unregisterRemovesOnlyTheInstalledHandlerNotALaterReplacement() { + ConnectionHandler table = handler(); + CommandRegistrar registrar = table.commandRegistrar(); + CommandHandler provider = (session, message) -> {}; + CommandHandler admin = (session, message) -> {}; + registrar.register(Command.OPER, provider); + assertThat(registrar.isRegistered(Command.OPER)).isTrue(); + registrar.register(Command.OPER, admin); // admin enabled later takes OPER over + registrar.unregister(Command.OPER, provider); // the provider is disabled + assertThat(registrar.isRegistered(Command.OPER)).isTrue(); + registrar.unregister(Command.OPER, admin); + assertThat(registrar.isRegistered(Command.OPER)).isFalse(); + } + + /** Two distinct handlers that consider themselves equal, as a value-like handler might. */ + private static final class EqualHandler implements CommandHandler { + @Override + public void handle(ClientSession session, net.jircd.protocol.Message message) { + // Not invoked. + } + + @Override + public boolean equals(Object other) { + return other instanceof EqualHandler; + } + + @Override + public int hashCode() { + return 1; + } + } + + @Test + void unregisterComparesTheInstalledHandlerByIdentityNotEquality() { + CommandRegistrar registrar = handler().commandRegistrar(); + CommandHandler provider = new EqualHandler(); + CommandHandler replacement = new EqualHandler(); + registrar.register(Command.OPER, provider); + registrar.register(Command.OPER, replacement); + registrar.unregister(Command.OPER, provider); + assertThat(registrar.isRegistered(Command.OPER)).isTrue(); + registrar.unregister(Command.OPER, replacement); + assertThat(registrar.isRegistered(Command.OPER)).isFalse(); + } +} diff --git a/jircd-core/src/test/java/net/jircd/core/session/PreparedDeliveryTest.java b/jircd-core/src/test/java/net/jircd/core/session/PreparedDeliveryTest.java new file mode 100644 index 0000000..86b9b88 --- /dev/null +++ b/jircd-core/src/test/java/net/jircd/core/session/PreparedDeliveryTest.java @@ -0,0 +1,277 @@ +/* + * 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.io.IOException; +import java.io.InterruptedIOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import net.jircd.core.extension.capability.RuntimeSnapshot; +import net.jircd.protocol.Command; +import net.jircd.protocol.Message; +import net.jircd.protocol.MessageSerializer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +@Timeout(15) +class PreparedDeliveryTest { + + private static ClientSession session() { + return new ClientSession("prepared", "host", RateLimitBucket.withDefaults()); + } + + private static Message frame(String text) { + return new Message(Map.of(), "srv", Command.NOTICE, "NOTICE", List.of("#c", text)); + } + + private static PreparedDelivery delivery(Message... frames) { + return new PreparedDelivery(FrameSequence.of(List.of(frames)), RuntimeSnapshot.EMPTY); + } + + @Test + void sequenceMaterializesExactBytesIncludingTagsAndCrlf() { + Message tagged = + new Message( + Map.of("a", "b c;\\"), "srv", Command.NOTICE, "NOTICE", List.of("#é", "€ text")); + Message bare = new Message(Map.of(), null, null, "BATCH", List.of("+ref", "type")); + FrameSequence sequence = FrameSequence.of(List.of(bare, tagged)); + String taggedLine = MessageSerializer.serializeText(tagged); + String bareLine = MessageSerializer.serializeText(bare); + assertThat(sequence.lines()).containsExactly(bareLine, taggedLine); + assertThat(sequence.bytes()) + .isEqualTo( + (long) bareLine.getBytes(StandardCharsets.UTF_8).length + + 2 + + taggedLine.getBytes(StandardCharsets.UTF_8).length + + 2); + } + + @Test + void sequenceIsImmutableAndIndependentOfItsSource() { + List source = new ArrayList<>(List.of(frame("one"))); + FrameSequence sequence = FrameSequence.of(source); + source.add(frame("two")); + assertThat(sequence.lines()).hasSize(1); + List lines = sequence.lines(); + assertThatThrownBy(() -> lines.add("x")).isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void rejectsEmptyAndUnserializableSequencesAtPreparation() { + assertThatThrownBy(() -> FrameSequence.of(List.of())) + .isInstanceOf(IllegalArgumentException.class); + Message oversized = + new Message(Map.of(), null, Command.TOPIC, "TOPIC", List.of("#c", "x".repeat(600))); + List frames = List.of(frame("ok"), oversized); + assertThatThrownBy(() -> FrameSequence.of(frames)) + .isInstanceOf(MessageSerializer.MessageTooLongException.class); + } + + @Test + void rejectsFramesWhoseSyntaxWouldSplitOrCorruptTheWireLine() { + Message[] bad = { + new Message(Map.of(), "srv", Command.NOTICE, "NOTICE", List.of("#c", "a\r\nQUIT :x")), + new Message(Map.of(), "srv", Command.NOTICE, "NOTICE", List.of("#c\nQUIT", "text")), + new Message(Map.of(), "srv", Command.NOTICE, "NOTICE", List.of("#c", "nul\0")), + new Message(Map.of(), "sr v", Command.NOTICE, "NOTICE", List.of("#c", "x")), + new Message(Map.of(), "srv", Command.NOTICE, "NOTICE\r\nQUIT", List.of("#c", "x")), + new Message(Map.of(), "srv", Command.TOPIC, "TOPIC", List.of("has space", "x")), + new Message(Map.of("a b", "v"), "srv", Command.NOTICE, "NOTICE", List.of("#c", "x")), + new Message(Map.of("a;b", "v"), "srv", Command.NOTICE, "NOTICE", List.of("#c", "x")), + new Message(Map.of("a", "v\0"), "srv", Command.NOTICE, "NOTICE", List.of("#c", "x")), + }; + for (Message message : bad) { + List frames = List.of(message); + assertThatThrownBy(() -> FrameSequence.of(frames)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Test + void enforcesTheServerOriginTagBudgetNotJustTheCombinedSection() { + Message oneServerTag = + new Message( + Map.of("big", "x".repeat(4090)), "srv", Command.NOTICE, "NOTICE", List.of("#c", "x")); + assertThat(FrameSequence.of(List.of(oneServerTag)).lines()).hasSize(1); + Message overBudget = + new Message( + Map.of("big", "x".repeat(4091), "+c", "y".repeat(3000)), + "srv", + Command.NOTICE, + "NOTICE", + List.of("#c", "x")); + List frames = List.of(overBudget); + assertThatThrownBy(() -> FrameSequence.of(frames)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("4094"); + } + + @Test + void admittedSequenceIsWrittenInOrderAndReleasesItsReservationOnce() throws Exception { + try (GatedOutput out = new GatedOutput(); + SessionWriter writer = new SessionWriter(session(), out, TagRenderer.NONE, 16384)) { + writer.enqueueRaw(Message.of(Command.PING, "block")); + out.awaitWrite(); + PreparedDelivery prepared = delivery(frame("one"), frame("two"), frame("three")); + writer.enqueue(prepared); + assertThat(writer.queuedBytes()).isEqualTo(prepared.frames().bytes()); + out.releaseAll(); + out.awaitWrites(4); // the PING plus the three frames + assertThat(writer.queuedBytes()).isZero(); + String text = out.text(); + assertThat(text.indexOf("one")).isLessThan(text.indexOf("two")); + assertThat(text.indexOf("two")).isLessThan(text.indexOf("three")); + } + } + + @Test + void aSequenceThatDoesNotFitIsRejectedWholeWithNoPartialAdmission() throws Exception { + AtomicInteger overflows = new AtomicInteger(); + PreparedDelivery tooBig = + delivery(frame("a".repeat(100)), frame("b".repeat(100)), frame("c".repeat(100))); + long perFrame = tooBig.frames().bytes() / 3; + int limit = (int) (perFrame * 2 + 10); // each frame fits, the whole sequence does not + try (GatedOutput out = new GatedOutput(); + SessionWriter writer = new SessionWriter(session(), out, TagRenderer.NONE, limit)) { + writer.setOnSendqOverflow(overflows::incrementAndGet); + writer.enqueueRaw(Message.of(Command.PING, "block")); + out.awaitWrite(); + writer.enqueue(tooBig); + assertThat(overflows).hasValue(1); + assertThat(writer.queuedBytes()).isZero(); + out.releaseAll(); + writer.close(); + assertThat(out.text()).doesNotContain("aaaa").doesNotContain("bbbb").doesNotContain("cccc"); + assertThat(out.text()).contains("SendQ exceeded"); + } + } + + @Test + void overflowAfterAdmissionDiscardsTheWholeBacklogAndReleasesEveryReservation() throws Exception { + AtomicInteger overflows = new AtomicInteger(); + PreparedDelivery first = delivery(frame("a".repeat(100)), frame("b".repeat(100))); + int limit = (int) first.frames().bytes() + 20; + try (GatedOutput out = new GatedOutput(); + SessionWriter writer = new SessionWriter(session(), out, TagRenderer.NONE, limit)) { + writer.setOnSendqOverflow(overflows::incrementAndGet); + writer.enqueueRaw(Message.of(Command.PING, "block")); + out.awaitWrite(); + writer.enqueue(first); + assertThat(writer.queuedBytes()).isEqualTo(first.frames().bytes()); + writer.enqueue(delivery(frame("d".repeat(100)))); + assertThat(overflows).hasValue(1); + assertThat(writer.queuedBytes()).isZero(); + out.releaseAll(); + } + } + + @Test + void admissionAfterCloseIsIgnored() throws Exception { + try (GatedOutput out = new GatedOutput(); + SessionWriter writer = new SessionWriter(session(), out, TagRenderer.NONE, 16384)) { + out.releaseAll(); + writer.close(); + writer.enqueue(delivery(frame("late"))); + assertThat(writer.queuedBytes()).isZero(); + assertThat(out.text()).doesNotContain("late"); + } + } + + @Test + void queuedSequenceHoldsNoProviderCallbackOrMutableState() { + // A PreparedDelivery is only strings, byte counts and the immutable captured view. + for (var component : PreparedDelivery.class.getRecordComponents()) { + assertThat(component.getType()).isIn(FrameSequence.class, RuntimeSnapshot.class); + } + for (var field : FrameSequence.class.getDeclaredFields()) { + assertThat(field.getType()).isIn(List.class, long.class); + } + } + + private static final class GatedOutput extends OutputStream { + private final Semaphore started = new Semaphore(0); + private final Semaphore permits = new Semaphore(0); + private final Semaphore completed = new Semaphore(0); + private final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + @Override + public void write(int b) throws IOException { + write(new byte[] {(byte) b}); + } + + @Override + public void write(byte[] data, int offset, int length) throws IOException { + started.release(); + try { + permits.acquire(); + } catch (InterruptedException _) { + Thread.currentThread().interrupt(); + throw new InterruptedIOException(); + } + synchronized (bytes) { + bytes.write(data, offset, length); + } + completed.release(); + } + + void awaitWrites(int count) throws InterruptedException { + assertThat(completed.tryAcquire(count, 5, TimeUnit.SECONDS)).isTrue(); + } + + void awaitWrite() throws InterruptedException { + assertThat(started.tryAcquire(5, TimeUnit.SECONDS)).isTrue(); + } + + void releaseAll() { + permits.release(100000); + } + + String text() { + synchronized (bytes) { + return bytes.toString(StandardCharsets.UTF_8); + } + } + } + + @Test + void tagKeysMustMatchTheIrcv3KeyGrammar() { + for (String key : + List.of("a\tb", "a\u0001b", "a\u007fb", "a_b", "+", "vendor/", "/name", "a/b/c")) { + List frames = + List.of( + new Message(Map.of(key, "v"), "srv", Command.NOTICE, "NOTICE", List.of("#c", "x"))); + assertThatThrownBy(() -> FrameSequence.of(frames)) + .as("key %s", key.replace("\t", "\\t")) + .isInstanceOf(IllegalArgumentException.class); + } + for (String key : List.of("time", "msgid", "+draft/reply", "example.com/foo-bar", "+typing")) { + List frames = + List.of( + new Message(Map.of(key, "v"), "srv", Command.NOTICE, "NOTICE", List.of("#c", "x"))); + assertThat(FrameSequence.of(frames).lines()).hasSize(1); + } + } +} diff --git a/jircd-protocol/src/main/java/net/jircd/protocol/MessageSerializer.java b/jircd-protocol/src/main/java/net/jircd/protocol/MessageSerializer.java index 77178c9..cb181d0 100644 --- a/jircd-protocol/src/main/java/net/jircd/protocol/MessageSerializer.java +++ b/jircd-protocol/src/main/java/net/jircd/protocol/MessageSerializer.java @@ -94,6 +94,21 @@ public static int tagDataBytes(Map tags) { return section.isEmpty() ? 0 : utf8Bytes(section) - 2; } + /** + * Whether the escaped tag data fits {@code limit} bytes. The raw character count is a lower bound + * of the escaped UTF-8 size, so an oversized map is rejected before any escaped section is built. + */ + public static boolean tagDataBytesAtMost(Map tags, long limit) { + long raw = 0; + for (Map.Entry entry : tags.entrySet()) { + raw += (long) entry.getKey().length() + entry.getValue().length(); + if (raw > limit) { + return false; + } + } + return tagDataBytes(tags) <= limit; + } + private static String serializeTags(Map tags) { if (tags.isEmpty()) { return ""; diff --git a/jircd-protocol/src/test/java/net/jircd/protocol/MessageSerializerTest.java b/jircd-protocol/src/test/java/net/jircd/protocol/MessageSerializerTest.java index f2c6a5a..611c67d 100644 --- a/jircd-protocol/src/test/java/net/jircd/protocol/MessageSerializerTest.java +++ b/jircd-protocol/src/test/java/net/jircd/protocol/MessageSerializerTest.java @@ -182,4 +182,17 @@ void utf8ValidatorAcceptsValidAndRejectsInvalidSequences() { byte[] truncatedMultiByte = {(byte) 0xE2, (byte) 0x82}; // incomplete 3-byte sequence assertThat(Utf8Validator.isValidUtf8(truncatedMultiByte)).isFalse(); } + + @Test + void tagDataBytesAtMostAgreesWithTheExactCountAndRejectsOversizeFromRawLength() { + Map escaped = Map.of("a", "x;y"); // a=x\:y is 6 bytes + assertThat(MessageSerializer.tagDataBytesAtMost(escaped, 6)).isTrue(); + assertThat(MessageSerializer.tagDataBytesAtMost(escaped, 5)).isFalse(); + assertThat(MessageSerializer.tagDataBytesAtMost(Map.of(), 0)).isTrue(); + assertThat(MessageSerializer.tagDataBytesAtMost(Map.of("a", "é"), 4)).isTrue(); + assertThat(MessageSerializer.tagDataBytesAtMost(Map.of("a", "é"), 3)).isFalse(); + // Longer raw than the limit cannot fit: decided without building the escaped section. + assertThat(MessageSerializer.tagDataBytesAtMost(Map.of("a", "x".repeat(20_000_000)), 4094)) + .isFalse(); + } } diff --git a/jircd-server/src/main/java/net/jircd/server/JircdServerApplication.java b/jircd-server/src/main/java/net/jircd/server/JircdServerApplication.java index 853ee9a..191f537 100644 --- a/jircd-server/src/main/java/net/jircd/server/JircdServerApplication.java +++ b/jircd-server/src/main/java/net/jircd/server/JircdServerApplication.java @@ -124,7 +124,10 @@ public JircdServerApplication(Path configPath, Consumer acceptedSocketCu .map(net.jircd.core.extension.Extension::id) .collect(java.util.stream.Collectors.toSet()); ConfigurationLoader loader = - new ConfigurationLoader(knownCapabilityIds, knownServerExtensionIds); + new ConfigurationLoader( + knownCapabilityIds, + knownServerExtensionIds, + extensionRegistry::discoveredReplyBudgetCatalog); try (InputStream in = new FileInputStream(configPath.toFile())) { this.configuration = loader.load(in); @@ -167,7 +170,8 @@ public JircdServerApplication(Path configPath, Consumer acceptedSocketCu () -> serverName, reloader, disconnectCleanup, - connectionHandler::registerHandler)); + connectionHandler.commandRegistrar(), + extensionRegistry.capabilityServices())); enableConfiguredExtensions(); } diff --git a/specs/014-capability-plugin-spi/checklists/requirements.md b/specs/014-capability-plugin-spi/checklists/requirements.md new file mode 100644 index 0000000..3775d11 --- /dev/null +++ b/specs/014-capability-plugin-spi/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Capability Plugin Ownership and Runtime Lifecycle + +**Purpose**: Validate revised design before implementation review +**Created**: 2026-10-22 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] Requirements describe observable value for operators/capability authors; concrete interfaces live in contracts +- [x] All mandatory specification sections completed +- [x] User's three decisions take precedence over earlier design defaults + +## Requirement Completeness + +- [x] No unresolved clarification markers +- [x] Functional requirements and success criteria are measurable and task-covered +- [x] Existing behavior preserved except the explicitly planned CAP LS updates and lifecycle changes +- [x] Old captured/preparing output may finish after DEL; later captures use the new immutable snapshot +- [x] Snapshot publication and ConnectionHandler.liveSessions pruning are one administrative-mutex step, followed by notifications +- [x] Whole admitted batches keep their close; ineffective prerequisites select plain before group admission +- [x] cap-notify is advertised core infrastructure and positive REQ follows the offered catalog +- [x] Stop runs outside core locks with thread-safe overlap; runtime fault withdrawal/diagnostics complete in #102 +- [x] Dependencies, reply/tag/SendQ bounds, hot-path measurements and future-feature exclusions retained + +## Delivery Readiness + +- [x] Four ordered PRs: #100, #101, #97, #102 +- [x] Every task has one PR owner and each PR ends with a green build checkpoint +- [x] Tests precede relevant changes; five remaining providers migrate one at a time in #101 +- [x] #100/#101 retain existing integration sources; #97 updates exact-LS/provider-absence expectations without unrelated weakening +- [x] Per-PR real-use/review/performance evidence and final SC-005 dedicated load proof assigned +- [x] Read-only speckit-analyze and deterministic cross-artifact checks completed after revision; findings remediated outside analysis as authorized + +## Notes + +No implementation/build/irctest result is claimed here. The four PRs implement the final contract incrementally. Accepted risks are old captured output after DEL, provider-owned safe callback/start/stop overlap, existing-mutex contention and materialization costs. A newly observed conformance failure must be reported, never hidden. Analysis checks requirement coverage, task ownership/order, terminology and constitution gates; the read-only pass found capture-time wording, #97 unit-fixture ownership and #100 diagnostic-boundary clarifications; all three were corrected. Final coverage is 22/22 FR/SC items across 66 tasks, with no remaining findings. diff --git a/specs/014-capability-plugin-spi/contracts/capability-spi.md b/specs/014-capability-plugin-spi/contracts/capability-spi.md new file mode 100644 index 0000000..0a3b9f6 --- /dev/null +++ b/specs/014-capability-plugin-spi/contracts/capability-spi.md @@ -0,0 +1,112 @@ +# Contract: Capability Plugin SPI + +Implements FR-001–004, FR-010–016. Complements [runtime lifecycle](runtime-lifecycle.md) and [reply budgets](reply-budgets.md). Names below are proposed public types/methods, not implementation code. They belong under `net.jircd.core.extension.capability` unless an existing type is named. + +## Ownership and registration + +`CapabilityExtension` remains an `Extension` discovered by ServiceLoader and provides exactly one `Capability`. Add a registration entry point `register(CapabilityRegistrar)`; `AbstractCapabilityExtension` retains id/capability boilerplate, with documented no-op lifecycle defaults. `start(ServerContext)` prepares resources; registration is staged, never visible until successful publication. `stop()` runs after withdrawal, outside core locks; it may overlap callbacks using an older captured snapshot. + +`CapabilityDescriptor` declares provider id, capability name, hard provider dependencies, and deterministic hook registrations. Names/ids are nonempty protocol-safe tokens; capability name is unique across discovery and may not claim core `cap-notify`. Duplicate ids/offers cannot silently replace another provider. A provider must not mutate its descriptor or registrations after staging. Registration handles belong to their provider and close idempotently. Captured immutable hook definitions remain usable after removal from the current table; withdrawal affects new captures. + +Extend `ServerContext` with a capability-services handle: events, structured replies, authorized delivery, scoped command registration and runtime snapshots. Keep existing operational services for ServerExtension. A capability receives a provider-owned facade; it cannot negotiate on another session's behalf. Its captured inputs and returned immutable plans may outlive withdrawal. No core-to-capability-module dependency. + +The registrar supports these typed registrations. Each records an owner, stage/slot, negotiation prerequisites, bounds and fallback. Dispatch applies owner state and declared negotiation gates centrally; providers do not query mutable session capability sets themselves. + +| Registration | Inputs | Result | Gate/default | +| --- | --- | --- | --- | +| `onEvent(kind, hook)` | CommittedEvent with immutable subject, pre/post values and an audience handle | Bounded delivery intents to permitted interested peers | Enabled provider; recipient negotiation gates resulting intents. Subject need not negotiate; no listener means no audience traversal | +| `onInboundTags(hook)` | Parsed bounded tags, command facts, sender snapshot, RequestContext | Sanitized client-tag selection and/or provider-scoped context | Enabled provider; optional sender prerequisites declared per registration; default ignores semantic tags | +| `onRecipients(hook)` | Authorized DeliveryIntent, sender snapshot, immutable base audience, RequestContext | Additional sender-copy decision/roles only within permitted scope | Owner negotiated by sender; default ordinary core recipient rules | +| `onOutboundTags(hook)` | Immutable OutboundMessage + role/request metadata when declared recipient-dependent | Owned validated tag contribution with origin metadata | Owner negotiated by recipient; default empty | +| `onMemberStatus(hook)` | MemberSnapshot ordered held statuses, reply kind and requester snapshot | Replacement status string in allowed PREFIX order | Owner negotiated by requester; default highest status | +| `onNamesIdentity(hook)` | MemberSnapshot's already-permitted nick/ident/display-host fields | Replacement NAMES identity token | Owner negotiated by requester; default nickname | +| `onReplyField(slot, hook)` | A schema-declared extension slot and immutable reply facts | Bounded field/text contribution allowed by that schema | Owner negotiated by requester; default schema value/omission | +| `onOutputGroup(hook)` | Finite logical OutputGroup, recipient snapshot and RequestContext | FramingPlan: complete ordered messages, including any open/close | Owner plus declared session prerequisites; default plain members | +| `command(command, handler)` | Core-parsed command, sender snapshot and scoped services | Command's logical replies/delivery intents | Enabled provider unless explicit sender gate; default generic unknown-command handling when no provider owns it | + +Core MessageParser/serializer continue to recognize tag syntax and wire tokens even with no provider. Semantic tag forwarding, msgid selection, TAGMSG behavior, batching syntax and full status selection live in modules. Ordinary IRC parsing, authorization, visibility and highest-status formatting remain core behavior. + +## Event contract + +Event kinds implemented now: `AWAY_CHANGED`, `JOINED`, `PARTED`, `QUIT`, `NICK_CHANGED`. A future account implementation adds `ACCOUNT_CHANGED` at the actual account-state commit boundary; do not emit fake account events or build an account subsystem here. + +- Emit after a successful state transition, once; no event for rejected/no-op operations. Existing AWAY semantics treat every accepted set/change/clear as the trigger, even if the supplied reason is repeated; preserve current behavior. +- AWAY includes old/new reason and presented subject identity; JOIN includes committed join/member facts; PART/QUIT include pre-removal audience/identity; NICK includes old presented identity and new nickname. QUIT cleanup is idempotent. +- Capture state/audience facts under existing state locks as needed, then release all registry/channel locks before extension evaluation or delivery. Preserve current weak membership-snapshot semantics; do not promise an atomic snapshot of all channels. A member's privileges are internally consistent. +- `Audience` is a core-owned lazy, deduplicated peer view, not a mutable global registry. Shared-channel neighbors are collected at most once per event. Subject inclusion is explicit; away-notify excludes self. A departing user's peers are captured before memberships disappear. Disconnected recipients are skipped. +- Plugin hooks return intents. Core validates the requested audience/identity and applies recipient negotiation at final admission. Plugins cannot widen private/secret visibility, see unauthorized real hostnames, undo core state or send while holding a domain lock. +- Event hooks are synchronous, cooperative and nonblocking. They must not perform network/file I/O or recursively mutate lifecycle. No general executor/event bus is introduced. + +## Message delivery and tags + +Extract the reusable portion of MessageCommandHandler target resolution/send gates into `MessageDelivery` (same core session package). It returns an authorized intent or the existing fitted error. PRIVMSG/NOTICE and module-owned TAGMSG share it; NOTICE silence and all ban/mode gates remain core policy. A command extension cannot bypass gates by supplying an arbitrary peer set. + +Delivery roles include ordinary target, sender copy and correlated request reply. Core's default channel delivery omits sender; direct self-target delivers once. echo-message adds a sender-copy role for permitted PRIVMSG/NOTICE/TAGMSG. Deduplicate by recipient + logical envelope id and merge roles; no second self-delivery, no bypass of TAGMSG recipient gates. Preserve original ordering/error behavior and one msgid per envelope. + +Inbound tag syntax/size validation is always on in protocol/core. message-tags owns the existing client-tag selection (client-only `+` tags), strips client attempts to supply server-owned tags, creates msgid from the immutable envelope id and requires its recipient capability for tag-only delivery. Client-only tags continue to be accepted from a sender without message-tags negotiated where current wire behavior permits; do not add a new sender gate. Other capabilities can claim inbound semantic keys such as a future label through their own sender gate/context namespace. + +Tag phase order: inbound normalization → immutable envelope metadata → recipient policy → server/client contribution composition → field/text fitting → group framing → final validation/admission. server-time uses envelope sentAt and remains independent of message-tags. Server tags do not need a negotiated message-tags capability merely because they use tag framing (preserves server-time semantics). + +Outbound pure hooks must return the same contribution for the same immutable input; msgid/time use envelope metadata. Cache validated immutable results and exact escaped byte sizes per envelope and registered hook identity. This identity is only a memoization key, never a lifecycle version or admission token. Hooks marked recipient-dependent use a bounded per-preparation result, not a global recipient cache. No provider callback or mutable map survives into the writer queue. A captured delivery keeps its original logical message id/time and is not rechecked or retried on a lifecycle change. + +Each server tag key/namespace has one owner per active composition. Client-only `+` namespace belongs to forwarding policy; server keys cannot be supplied there. `batch` tag is owned by the batch shaper, not ordinary tag contributors. Reject conflicting registration, invalid keys/values and conflicting output rather than relying on iteration order. Empty valid contribution is distinct from null. Validate aggregate per-origin and total limits; see budgets contract. + +## Structured reply composition + +`MemberSnapshot` holds member identity permitted for the requester and a consistent ordered list of all held status symbols, derived from generic channel privileges/PREFIX catalog. Core's normal field is the highest status. No `multiPrefix` boolean, CapabilityName check or capability-specific helper remains in Channel/ChannelRegistry. + +- multi-prefix replaces the status slot with all held symbols in PREFIX order for 353 and channel 352. +- NAMES identity is a separate slot so a later userhost-in-names provider can produce `nick!ident@displayHost` independently of multi-prefix. Display-host inputs follow the normal cloak/privacy boundary. +- WHO core owns H/G and IRC-oper `*` flags. The status slot only appends channel statuses; exact-nick/mask WHO keep their current behavior. +- At most one enabled provider replaces a given slot; multiple different slots compose in schema order. A declared additive slot can have several owners only with deterministic provider-id order and summed bounds. Dependencies are not an implicit override mechanism. +- `ReplyLayout` defines slots allowed for that numeric/command, fixed fields, list/text positions and fit policy. A provider cannot append arbitrary WHO parameters or alter numeric arity; a future protocol extension needing new fields must define an appropriate schema first. This is how “extra fields where the protocol permits them” is bounded. +- Capture unformatted member facts once for JOIN results, not capability-shaped strings. JOIN, targeted/bare NAMES, SAJOIN and WHO use the same structured service. Plugin failure after JOIN never loses the successful JOIN or leaves an empty channel. + +## Output groups and future request context + +`OutputGroup` contains immutable logical members, type/type parameters, group id, optional parent group, RequestContext and logical byte upper bound. Input is finite and must fit the recipient's SendQ envelope before a full transformed copy is allocated. Group nesting is validated acyclic and bounded by admitted bytes; no open-ended streaming API is supplied. + +Batch registers a group shaper with owner=batch and session prerequisite message-tags. It generates a fresh valid opaque reference per recipient/group, emits the existing prefixless `BATCH +ref type params`, tags members, and emits `BATCH -ref`. Missing owner/prerequisite produces exactly plain members. Existing raw trusted member tags are preserved and independently validated; no hidden global stripping rule is introduced. + +All frames, including close and nested framing, are validated/reserved and queued in a single generic admission. Either all are admitted or none. No shaper callback runs during drain. An already-emitted start therefore always has a queued close unless the connection terminates; no ordering of that close relative to DEL is required. Prepare validated framed/plain alternatives outside locks. At final group admission, use the current owner/session prerequisite set to select an alternative atomically with appending it; if prerequisites became ineffective, send plain members. Use the existing administrative mutex for that short group-only selection/append, followed by the writer enqueue lock; ordinary output has no lifecycle admission lock or revalidation. Future chathistory pages must be finite groups, with paging/storage policy in that feature. + +`RequestContext` is immutable, scoped to one inbound request and propagated to its synchronous logical replies and explicit completion boundary. It has a core request id plus provider-owned bounded attributes; core does not interpret `label`. Unsolicited peer broadcasts do not inherit a request label. Future labeled-response uses inbound tag, response tag and grouping hooks to label one reply, wrap many replies or acknowledge none; it can distinguish sender-copy from ordinary delivery and avoid a duplicate acknowledgement when an echo is the response. Its wire semantics remain future work. Pipeline stages/roles resolve composition without optional plugins calling each other by name. + +## Dependencies + +- `requiresProviders`: hard availability dependency ids in an acyclic graph. Cannot enable without all required providers; disable/failure withdraws transitive dependents atomically. Duplicate/self/unknown/cyclic declarations are invalid. Explicit re-enable is required for withdrawn dependents. +- `requiresNegotiated`: registration-level session prerequisites evaluated with owner negotiation in one immutable snapshot. Unmet gate uses the declared default and does not withdraw the owner's offer or negotiated name. +- An optional interaction uses typed context/stages, not a hard dependency. No undeclared global registry lookup inside hooks. +- Batch uses only a session-level message-tags prerequisite; batch-alone requests still ACK, and disabling message-tags leaves batch offered/negotiated but inert for framing. A new later message-tags request may reactivate that framing without re-requesting the still-held batch name. + +## Failure policy + +| Boundary | Invalid/throwing behavior | Required action | +| --- | --- | --- | +| Descriptor/registration | Null data, duplicates, impossible bounds, ownership conflict/cycle | Reject staging; no live tables changed. Invalid provider is reported FAILED; unrelated valid providers remain usable. Operator configuration validation errors leave the prior configuration untouched | +| Start | RuntimeException/LinkageError or partial resource acquisition | Close staging handles/resources, FAILED, no offer/NEW; hard dependents fail, unrelated providers continue | +| Event/recipient/tag/field/group/command hook | Exception, null, malformed, unauthorized audience/identity, exceeded bound, illegal framing | Drop that provider's entire invalid result, withdraw it as FAILED, use the documented default for that failed contribution; no lifecycle retry of valid captured work and no rollback of committed core state | +| Runtime composition | Combined valid results exceed a previously proven bound or conflict | Fault offending owner if attributable; reject unadmitted plan and produce one bounded actionable diagnostic. Never emit partial group or silently let writer drop a member | +| Stop/registration close | Exception after withdrawal | Continue other cleanup, keep withdrawn, record FAILED through the same lifecycle; never restore negotiations | +| Recipient overflow/disconnect | Queue cannot accept full plan/notification or socket fails | Existing overflow/disconnect cleanup for that recipient; do not mark plugin FAILED or roll back server transition | + +Catch ordinary runtime/linkage faults, not VirtualMachineError/ThreadDeath. Do not catch validation responses such as ordinary command rejection as plugin faults. Core fitting an allowed variable text/list is normal, not a plugin error. A pure hook result exceeding its own declared bound is a provider fault even if the resulting line happens to fit 512 bytes. + +A bounded, rate-limited diagnostic identifies provider id, hook and reason category on a failure transition; repeated faults/cleanup warnings for that provider are suppressed within the logging window. Do not log raw exception messages/stack payloads, client content/tags or configuration secrets. No automatic recovery: an administrator may explicitly enable after fixing the cause. Hooks are cooperative, nonblocking and thread-safe. Stop must preserve the safety of callbacks already using captured inputs, and providers must synchronize their own start/stop resource access if it can overlap those callbacks. Core does not track active calls, wait for completion, suppress valid old results or forcibly terminate hung code. A late fault is attributed to the provider identity and, if it is currently enabled, may withdraw it again after re-enable. This is the deliberate cost of the simpler in-process lifecycle. + +## Public API migration + +At implementation time document the following in Javadoc and README/PR: + +1. `contributeTags` becomes a registrar outbound-tag hook with owned keys, purity and validated byte metadata. Keep it during #100 for unmigrated providers; remove it in #101. +2. `includeSenderInFanOut(ClientSession)` becomes a sender-gated recipient hook with DeliveryRole/RequestContext; providers no longer read mutable negotiation state. Remove the legacy method in #101 after the last migration. +3. `ServerContext` gains capability services; migrate all in-repository construction sites/fixtures. No speculative external binary adapter is promised for 0.1.0-SNAPSHOT. +4. `Channel.memberListing(boolean)`/`statusPrefixes(boolean)` become generic member snapshots + reply rendering; update admin SAJOIN and unit fixtures. +5. `SessionWriter.enqueueBatch` callers move to generic `enqueueGroup`; any short-lived adapter delegates to registered shaping and contains no capability names. Remove adapter by final checkpoint. +6. Negotiated capabilities become immutable snapshots; mutations go through the coordinator. Unit setup uses a test negotiation helper, never `.add()` on the getter. + +Keep protocol constants/token vocabulary if useful, but remove its Javadoc claim that core handlers branch on concrete capability names. Document first-release version signaling for the SPI break, consistent with the review checklist's unreleased-project interpretation of constitution III. + +## Delivery checkpoints + +#100 provides registration validation, defensive hook exception/result containment and safe fallback so new SPI use cannot damage core outcomes, reporting failures through the existing sanitized operator-visible diagnostic path. It does not silently suppress errors or claim runtime FAILED withdrawal/final provider-level bounded diagnostic behavior. #101 migrates the remaining providers and removes the legacy hooks. #97 provides snapshot/prune/notification mechanics. #102 connects every hook fault to that FAILED withdrawal path and adds bounded diagnostics and dedicated load proof. Each PR is built/tested independently; final failure-policy acceptance applies to the completed four-PR feature. diff --git a/specs/014-capability-plugin-spi/contracts/reply-budgets.md b/specs/014-capability-plugin-spi/contracts/reply-budgets.md new file mode 100644 index 0000000..0b8cc3c --- /dev/null +++ b/specs/014-capability-plugin-spi/contracts/reply-budgets.md @@ -0,0 +1,66 @@ +# Contract: Reply, Tag and SendQ Budgets + +Implements FR-013/014, SC-003/005. This replaces capability-specific reply-width assumptions in ConfigurationLoader and HostmaskReplyBudget with generic schema/contribution bounds while preserving their current safe acceptance envelope. + +## Units and validation entry points + +All bounds count serialized UTF-8 bytes, including separators/escaping where relevant, never Java character counts. Arithmetic uses checked addition/multiplication; null, negative, overflowed or unbounded bounds reject registration. No hook may allocate an arbitrarily large output before checking its declared bound. + +A descriptor's reply bound is a deterministic function of validated configured limits and generic reply facts. Startup and REHASH validate the conservative envelope of all discovered valid provider decorations, including disabled providers that could later be enabled. Runtime enable validates actual active slot ownership/composition using the same checker. Validation never invokes per-user rendering hooks. Missing/invalid bound declarations are provider faults, not zero-byte defaults. + +The conservative catalog prevents routine toggles from invalidating previously accepted configuration. A larger future plugin requires startup/reload validation against its installed descriptor. Mutually exclusive replacement slots reserve their maximum, independent composable slots sum; additive slots sum all compatible owners. Report provider/slot/formula/configuration fields in actionable errors without sensitive values. Pure configuration validation errors publish nothing. + +## NAMES and WHO + +Let S = running server-name bytes, N = configured maximum nickname bytes, C = configured channel-name bytes, U = IDENT_MAX_BYTES and H = PRESENTED_HOST_MAX_BYTES. Use running server identity during reload, as ConfigurationReloader already does. P is maximum status-string bytes for the compatible registered status transforms; I is additional identity bytes beyond nickname; F = 2 + P reserves away H/G and IRC-oper `*` plus channel statuses. + +| Reply | Conservative complete command-section bound, including CRLF | +| --- | --- | +| One 353 member | `S + 2N + C + 13 + P + I` | +| 366 end of NAMES | `S + N + C + 29` | +| 352 WHO with empty fitted realname | `2S + 2N + C + U + H + 18 + F` | + +Core highest-status default has P <=1. multi-prefix declares the sum of supported PREFIX symbol bytes (2 for @/+ today), without naming that capability in core. With P=2/I=0, the formula is the current `S+2N+C+15`. WHO with F=4 exactly matches the current HostmaskReplyBudget assumption. A later userhost-in-names declares I=`U+H+2` for `!ident@host`; it must compose with P=2, not be checked only in isolation. Generic extra-field slots add all separators/field bytes permitted by their schema. + +Keep every other HostmaskReplyBudget entry (333, 311/314, 302, INVITE) and its existing limits. Core owns fixed fields and fit policy. Remove the multi-prefix-specific WHO_FLAGS_BYTES constant and ConfigurationLoader's hidden two-prefix allowance; express those bounds through layouts plus the installed catalog. Make startup/reload constructors, runtime enable and test construction use the same bound validator. + +At runtime collect/render complete member tokens before packing 353 lines; split only between tokens. Never truncate nick/status/ident/host midway. Apply fitting only to permitted free text (e.g. WHO realname), after every decoration. A normal fully valid configuration must be able to fit one worst-case member and its closing reply. If a provider lies about its bound, quarantine it and re-render using the remaining/default slots, preserving the core reply/366. A core-only unrepresentable identity is reported through the existing bounded OutboundSizeGuard warning, never a silent writer drop. Assert fitted text/content and closing numeric, not just numeric presence. + +## Tags + +Retain MessageSerializer constants and origin semantics: + +- Command section: at most 512 bytes including CRLF, independently of tags. +- Client inbound tag section: at most 4096 bytes including `@` and separating space (existing ConnectionHandler limit). +- Each forwarded-client/server tag origin: at most 4094 data bytes under MessageSerializer's exact escaped accounting. +- Combined outbound tag section: at most 8191 bytes including framing/separators. + +A tag hook declares ownership and origin, returns immutable validated data, and is measured using the real serializer's escaping rules. Count semicolons, equals signs, escaped spaces/backslashes/CR/LF and UTF-8 expansion. Forwarded `+` tags and server contributions are checked separately and again after deterministic merge. Duplicate keys/forged server keys cannot hide bytes or override another owner's contribution. + +Memoize a pure provider's validated tag map AND exact byte metadata per envelope/hook identity; the identity is a computation-sharing key only, not a lifecycle counter. Do not invoke a provider once to measure and again on drain, or keep a deferred supplier capturing a provider. A per-recipient plan may reuse these immutable results; share common work rather than copying every map on every fan-out. A recipient-dependent contribution declares that fact and is evaluated once for that preparation. + +Adding batch tags or later label/group metadata participates in the server origin budget. Validate complete member messages after shaping. A group with insufficient tag room is handled before any opener is admitted; never silently drop a tagged member during drain. Attributable plugin defects fail that owner; valid bounded data hitting a documented size limit uses the declared fallback/rejection and a bounded diagnostic without faulting unrelated providers. + +## Queue accounting and bounded preparation + +The core writer admits only validated immutable FrameSequences. Its reservation counts exact serialized command + tag section + CRLF bytes for every frame, including open/close/nested wrappers, before publication. The queue holds no plugin callbacks or mutable state. Queue and in-flight accounting retain existing SendQ semantics and are released exactly once on dequeue/overflow/disconnect. + +For groups, reject/preflight by a conservative byte bound before building a full framing copy, reserve/refine to exact size and atomically append the entire sequence. If it cannot fit, admit no prefix and use existing SendQ overflow cleanup for that recipient. Plain fallback still counts and validates all member bytes. NEW/DEL/ACK are charged like ordinary output; a full SendQ disconnects only that session and cannot stall/roll back the lifecycle transaction. + +Ordinary output uses the runtime/session view captured before preparation and is appended without lifecycle revalidation. A state change may overtake it. This needs no extra queue beyond the existing SendQ. Groups prepare validated framed/plain alternatives outside locks; final group admission chooses using current owner/session prerequisites under the existing administrative mutex, then appends under one writer queue lock. Only this group choice is coordinated with state changes. All plugin calls, large copies and serialization remain outside those locks. An admitted group keeps its close; a not-yet-admitted group whose prerequisites went ineffective is plain. + +## Performance proof + +The following are acceptance budgets to measure, not achieved results: + +| Workload | Budget/evidence | +| --- | --- | +| Existing ordinary delivery / 1,000 live sessions | Message max <1 s; same-machine p99 and throughput compared with baseline | +| Existing JOIN storm with quiet channel | Storm <15 s, quiet p99 <500 ms and max <1 s | +| New mixed-capability fan-out | 10/100/1,000 recipients; same logical messages, payloads, negotiation mixes and rate settings; p99 <1 s; CPU/allocation/throughput comparison | +| New lifecycle publication (#102 final proof) | At 1,000 live sessions, administrative-mutex wait + immutable snapshot publication/all-session prune p99 <=100 ms; report wait and hold separately. Notification enqueue, config I/O, start/stop and socket drain are separate measurements. Do not wait for old captured output | +| Concurrent rendering/grouping/toggling | No deadlock; bounded reservations and operation completion. Old captured output after DEL is allowed. Assert current-view new captures, complete admitted groups and plain unadmitted groups when prerequisites are ineffective | + +Message throughput must regress by no more than 10% against the recorded main baseline unless explicitly justified in review. Latency/CPU/allocation increases above 10% also require optimization or recorded justification. Use a warmup and at least five identical measured runs per revision; report medians plus p99/max samples, machine/JDK/JVM settings, workload and raw result paths. Existing baseline has no lifecycle implementation: the new publication metric uses the absolute budget, not a fabricated relative comparison. + +Hot-path code review must show no registry-wide enabled() scan for each recipient; session hook views change only on negotiation/runtime revision. Event peers are collected once, bounded by shared memberships. Lifecycle is O(sessions × withdrawn names), not a per-message global scan. The existing administrative mutex, pre-materialized tags and group alternatives must be profiled. Each hot-path PR runs applicable existing loads; #97 records repeatable publication timings from its socket fixture, and #102 adds dedicated load classes and final SC-005 evidence. diff --git a/specs/014-capability-plugin-spi/contracts/runtime-lifecycle.md b/specs/014-capability-plugin-spi/contracts/runtime-lifecycle.md new file mode 100644 index 0000000..d5bd219 --- /dev/null +++ b/specs/014-capability-plugin-spi/contracts/runtime-lifecycle.md @@ -0,0 +1,83 @@ +# Contract: Immutable Capability Snapshot and Runtime Lifecycle + +Implements FR-005–011 and FR-016; amends `specs/001-ircv3-server/contracts/server-configuration.md` “Path equivalence”, its CAP sections and `data-model.md` “Extension”. Implementation updates canonical documents in #97 and completes their failure policy in #102. No older spec is edited in this design-only revision. + +## State and capture + +`CapabilityRuntime` is the capability-state authority behind ExtensionRegistry. An immutable RuntimeSnapshot contains the provider/offer tables, compiled hook indexes, dependency graph and validated capability-related configuration. Each ClientSession has an immutable explicit negotiated set and highest supported CAP version. The existing administrative control mutex protects snapshot publication, physical session pruning, CAP mutations and ConnectionHandler.liveSessions insertion/removal/attachment. + +Capture a RuntimeSnapshot reference and the relevant immutable session negotiation together under that mutex, then release it before hook evaluation. An output operation uses that captured view throughout preparation. It may finish and be queued after a later disable/DEL. Work prepared after a change captures the new view. The captured view remains valid data; ordinary output does not recheck it at admission or restart preparation because the current view changed. + +All accepted live sessions, including unnamed/pre-registration sessions and those awaiting writer attachment, are tracked before CAP processing. Reuse ConnectionHandler.liveSessions, not NicknameRegistry.all(). Insertion/removal and writer attachment use the same mutex, so no session can escape pruning. A session without notification eligibility needs no historical NEW/DEL replay. CAP LIST and CAP REQ capture current state under administrative control rather than combining independently read pointers. + +Provider states remain DISABLED, ENABLED or FAILED. Desired YAML state and actual state are distinct: a valid desired enable may result in FAILED if provider start code fails. No callback lifetime/version tracking is added. + +## Publication and notification sequence + +For disable, enable or failure, one serialized administrative operation does the following: + +1. Stage any new immutable registrations/resources outside core locks; validate candidate configuration, dependencies, slot/key ownership and reply budgets before live mutation. Invalid configuration leaves all active state untouched. +2. Under the existing administrative control mutex, calculate offered-set changes and any hard-dependent withdrawal; construct the new RuntimeSnapshot and physically remove withdrawn names from every session in ConnectionHandler.liveSessions. Swap the snapshot in that same critical section. A repeated no-op toggle changes nothing and queues no notification. Enable does not add negotiated names back. +3. After this state publication, queue CAP DEL/NEW for eligible live writers, in administrative transition order. The same control operation may retain the mutex while appending these already validated core frames; notification enqueue is a separately measured stage after publication. No socket I/O or plugin callback occurs under that mutex. Queue errors return dispositions for cleanup after unlocking. +4. Release all core locks before provider stop, registration cleanup, overflow/disconnect callbacks or other plugin callbacks. Stop failures keep the provider withdrawn and use FAILED reporting; no rollback or negotiation restoration occurs. + +The state publication and all-session prune are atomic to participating readers. Notification delivery is asynchronous and not atomic across sockets. A stalled/full writer cannot delay publication waiting for network I/O. Notification enqueue must be nonblocking and SendQ-bounded; overflow closes only that recipient through existing cleanup. + +Already captured/preparing work may appear after DEL, including after stop. There is no flush or promise of a strict output cutoff. New captures exclude withdrawn hooks/offers and pruned names. A late hook fault is attributed to its provider identity; if that provider has since been enabled again, the failure may withdraw its current offering. Providers must be thread-safe for overlapping captured callbacks and start/stop, without core waiting for active calls. No forceful termination of a hung trusted hook is provided. + +## Output and group admission + +Ordinary output is prepared outside core locks from its captured view, validated/materialized and appended under only the existing recipient queue lock. There is no lifecycle admission barrier or stale-work test. No provider callback or mutable map is stored in the writer queue, and a captured message retains its logical id/time. + +Batch is the narrow exception that needs a current prerequisite choice at group admission: + +- Prepare validated complete framed and plain alternatives outside locks, along with the shaper's generic owner/session prerequisites. The core never branches on batch/message-tags names. +- Under the administrative mutex, select framed only if its owner and declared prerequisites are currently effective for the recipient, then append the chosen complete sequence in one queue operation. Lock order for this short group-only path is administrative mutex → one writer enqueue lock. Do not invoke a shaper or serialize unbounded data while holding these locks. +- If batch or message-tags went ineffective before this group is admitted, select plain. No hook rerun, retry loop or extra deferred queue is needed. +- A group already admitted as one operation retains its close. An already-open batch therefore completes unless the connection terminates. DEL need not be ordered after the group's close; test well-formed framing rather than a general output cutoff. +- Normal per-session FIFO still applies to already queued frames. Reserve/validate all group bytes including close before append; overflow admits no partial group and closes only that connection. + +Never take administrative control from inside a writer enqueue lock or while holding channel/ChannelRegistry monitors. No two writer queue locks are held together. Plugin callbacks, stop and socket I/O run outside core locks. + +## Enable, dependencies and reload + +Hard provider dependencies are a validated DAG. Missing prerequisites reject an operator enable with an actionable error. Disabling/failing a hard prerequisite withdraws transitive hard dependents in the same publication/prune step. Enable never silently recovers those dependents or restores their negotiations. An explicit desired enable set, including a later valid REHASH, may retry them. Batch's message-tags prerequisite is session-level behavior only: batch remains offered/held, but new groups are plain until both are effective. + +Start/register stages resources outside core locks. On start failure, close staged registrations, leave no offer/NEW, mark the provider and unavailable hard dependents FAILED, and preserve unrelated working providers. Start/stop must be thread-safe with older captured callbacks; this SPI does not support dynamic classloader unload. + +REHASH/SIGHUP parse and validate the entire desired configuration before mutation, including bounds/dependencies. A validation failure changes no configuration, session membership or notification. A valid reload reconciles its capability set in one publication, using dependency order. Actual provider execution failures are isolated FAILED outcomes, not disguised as validation errors; successful unrelated changes may take effect. Publish validated capability configuration with the snapshot so readers capture coherent lengths/hook tables. Preserve unrelated reload behavior. + +EXTENSION uses the same publication path for a single desired state and reads/persists no YAML. REHASH reads the file. The observable lifecycle is equivalent for both paths. #97 implements disable/enable/negotiation and start-failure handling already supported by the registry; #102 connects all runtime hook faults and bounded diagnostics to this path. + +## CAP protocol amendments + +CAP notification semantics derive from [IRCv3 CAP](https://ircv3.github.io/specs/extensions/capability-negotiation.html); framing from [IRCv3 batch](https://ircv3.net/specs/extensions/batch.html). This design deliberately permits captured work after DEL as specified above; it does not claim the stronger output-cutoff guarantee. Record this known conformance tradeoff in the implementation/irctest report instead of hiding a newly exposed failure. + +| Operation | Required behavior | +| --- | --- | +| `CAP LS [version]` | Advertise core-owned cap-notify plus currently enabled provider capabilities. Remember highest supported version numerically; later lower/omitted version cannot clear >=302 notification support. Preserve registration pause rules | +| `CAP REQ :cap-notify` | Accept through the ordinary offered-name rule. It is always offered core infrastructure, not a toggleable Extension. Explicit membership appears in LIST | +| `CAP REQ :-cap-notify` | NAK whole request when highest version >=302. Older clients may remove it explicitly; already queued/captured notification work may finish | +| `CAP LIST` | Current explicit negotiated set after pruning. Omit implicit-only notification support; include explicitly negotiated cap-notify | +| Positive REQ token | Must be offered; no special acceptance for an unadvertised name. Idempotent request of an offered held name succeeds | +| Negative REQ token | Accept held name regardless of offers, known discovered name already pruned/inactive, or removable explicit cap-notify. Unknown unheld name NAKs | +| Mixed/repeated REQ | Validate candidate in token order, preserve raw echo/order/repeats, check reply size before mutation, commit one explicit set or NAK unchanged. Empty request NAKs; oversize echo returns existing 417 without mutation | +| Disable/failure | Queue `: CAP DEL :` after publication to all eligible clients, even those that never held removed names. Names only | +| Enable | Queue `: CAP NEW :` after publication. No automatic positive negotiation; future values only in LS/NEW | +| No offered-set change | No NEW/DEL for a no-op toggle or a session-only REQ change | +| No capability plugins | LS still advertises cap-notify; provider omission removes only the omitted provider's offer/behavior | + +CAP state operations, eligibility changes and their reply enqueue use administrative control so REQ/LS/LIST cannot observe partial pruning or lose a concurrent update. Hook evaluation is absent from this control path. Notification recipients/nickname target are captured as core facts during the operation; ordinary output is not synchronized to these replies. + +Parse nonnegative digit-only LS versions without integer overflow, saturating at supported semantics; malformed/negative is treated as absent for version tracking and preserves ordinary LS behavior. Test zero, empty, malformed, very large and lower/repeated versions. + +Names in NEW/DEL are deterministic and deduplicated. Split oversize notifications on token boundaries into independently valid lines; no LS continuation marker. Preserve fitting one-line LS/LIST replies; use supported 302 continuation when necessary. Legacy LS/LIST must fit one line: reject a catalog/enable whose mandatory core + provider offers cannot fit, rather than truncate. Validate maximum server/target headers at registration. Never split or alter ACK/NAK echoes. Core notification frames carry no provider tags. + +## Required evidence + +- Real-socket matrix {EXTENSION, REHASH} × {explicit legacy cap-notify, implicit >=302, legacy no-notify} × {multi-prefix, message-tags}. Check exact LS/DEL/NEW/LIST, current newly prepared NAMES/WHO/tag behavior, unnamed and never-held observers, re-enable without negotiation restoration. +- Update exact-LS expectations to include core cap-notify and prove it remains when any/all providers are absent. Restrict integration changes to that documented behavior; no unrelated assertion weakening. +- Negative REQ: controlled held-but-unoffered case, known already-pruned removal through real disable, unknown removal, unavailable positive/mixed/repeated/empty/oversize requests, sticky 302 semantics. Demonstrate #97 regression tests fail before the fix. +- Verify one snapshot/prune operation across all live sessions and concurrent CAP/session insertion. A captured old view may finish after DEL; the next capture must use new state. Do not write tests demanding suppression of old captured output. +- Group tests prove complete atomic append, retained close on admitted/open groups, and plain choice for a prepared but unadmitted group after a prerequisite becomes ineffective. No assertion that DEL is an output barrier. Use bounded coordination only to establish these retained behaviors, with finally cleanup. +- Stop runs outside locks, can overlap captured callbacks, and cannot roll back core state. Faults/overflow on one recipient do not break unrelated delivery; #102 adds real-socket fault injection for each wire-affecting hook. diff --git a/specs/014-capability-plugin-spi/data-model.md b/specs/014-capability-plugin-spi/data-model.md new file mode 100644 index 0000000..0b7d127 --- /dev/null +++ b/specs/014-capability-plugin-spi/data-model.md @@ -0,0 +1,72 @@ +# Data Model: Capability Runtime and Contributions + +Extends the original Extension, ClientSession and OutboundMessage models. All proposed views/contributions are in-memory and immutable once captured. Lifecycle is defined in [runtime-lifecycle.md](contracts/runtime-lifecycle.md). + +## CapabilityDescriptor and HookRegistration + +| Field | Rule | +| --- | --- | +| providerId, capabilityName | Nonempty protocol-safe token; unique across discovery; providers cannot claim core cap-notify | +| requiresProviders | Immutable set; no self, duplicate, unknown or cyclic hard dependency | +| registrations | Immutable ordered typed hooks, each with a provider owner; no mutation after staging | +| prerequisites/default | Enabled owner plus declared sender/recipient/session prerequisites from one captured view; explicit default | +| ownership | One replacement owner per reply slot/server-tag key; additive slots explicitly declared and deterministically ordered | +| bounds | Nonnegative finite byte bound evaluated from validated configuration; no null, overflow or unbounded result | + +Registration scope owns staged handles/resources. Close is idempotent. Removing registrations from the current table does not invalidate immutable hook definitions held by captured work. Provider start/stop and callbacks must be thread-safe with one another; core does not count/wait for active calls. + +## RuntimeSnapshot + +| Field | Rule | +| --- | --- | +| providers, states, offers | Immutable coherent tables; provider offers derive only from successful ENABLED entries; core cap-notify is always offered | +| hook indexes | Immutable typed arrays/maps compiled on state changes, deterministic stage/slot order | +| dependencies | Validated immutable DAG and reverse dependent closure | +| configured reply limits / desired capability states | Immutable validated capability configuration captured with the same snapshot, never independent old/new reads | + +Publish a new snapshot and physically prune every affected live session's explicit names in one administrative-mutex critical section. Then queue DEL/NEW. Capture snapshot + relevant session negotiation under the same mutex, use it through ordinary preparation without revalidation, and release the mutex before hooks. No per-message lifecycle version field is needed. + +## SessionNegotiation and live membership + +- `explicitNames`: immutable set; mutations only under administrative control. Getter returns a snapshot, never a mutable/live set. +- `highestCapVersion`: highest supported LS semantics ever indicated on this connection; >=302 support remains sticky. +- `notificationEligible`: derived from highest version >=302 OR explicit cap-notify, not independently mutable. +- `effectiveHooks`: immutable compiled selection for the captured snapshot/explicit names; reuse selection associated with those immutable inputs rather than scanning the registry per recipient. +- ConnectionHandler.liveSessions includes unnamed sessions before CAP handling; insertion/removal/writer attachment uses administrative control. NicknameRegistry is insufficient. + +Disable/failure removes withdrawn names physically. Enable does not restore them. Removing message-tags suppresses new batch framing without removing the still-held batch name. Core cap-notify remains offered independently of providers; implicit-only support stays out of LIST. + +## CommittedEvent, Audience and MemberSnapshot + +CommittedEvent holds kind, logical operation id, immutable subject before/after facts and permitted audience. Publish exactly once after a successful core transition, retaining existing repeated accepted-AWAY semantics. No event for rejected/no-op JOIN/nick or duplicate cleanup. + +Audience deduplicates permitted peers with explicit self policy and no global-session scan. Capture departure peers before removal. MemberSnapshot contains permitted nickname/ident/display-host, consistently ordered held statuses and generic reply facts; it contains no multi-prefix flag. Across members preserve existing weak snapshot semantics; core default is highest status + nickname. Release domain locks before any hook. + +## OutboundMessage, DeliveryIntent and RequestContext + +OutboundMessage has immutable command, presented sender identity, target/body, normalized client tags, one sentAt and logical message id. Keep shared ids/time across recipients and through captured work. Cache immutable validated pure contributions and exact escaped bytes by envelope/registered hook identity; this is memoization only, not lifecycle control. No provider callback is queued to drain. + +DeliveryIntent has core-authorized target/audience, envelope id and roles (ordinary target, sender copy, correlated request reply). Deduplicate recipient + envelope id and merge roles. No identity/visibility widening. + +RequestContext has core request id, bounded provider-namespaced immutable attributes and explicit completion boundary. No credentials/raw arbitrary context in diagnostics; unsolicited peer traffic does not inherit request correlation. No label-specific core branch. + +## ReplyLayout and ReplyContribution + +ReplyLayout specifies schema-permitted slots, fixed fields, list/text fit policy and fixed-byte formula. A contribution carries provider owner, slot/tag origin, immutable value and declared/actual bounds. Null/illegal/out-of-bound is a fault; empty valid values are allowed. Compose independent slots in schema order, fit variable fields and validate complete output. Core retains ordinary highest-prefix/identity privacy behavior. + +## OutputGroup, FramingPlan and PreparedDelivery + +OutputGroup has finite members, type/typeParams, logical id, optional parent id, RequestContext and conservative byte bound. Parent graph is acyclic; total/nesting memory is bounded by SendQ reservation. No unbounded stream handle. + +FramingPlan is a complete immutable ordered sequence including open/close, with exact bytes and an already validated plain alternative when applicable. Batch ref contains only ASCII letters/digits/hyphens and is unique per recipient/group. No partial admission or independently droppable close. Final group admission makes a current prerequisite selection, then appends the entire chosen sequence in one queue operation. If required capabilities went ineffective, choose plain. + +PreparedDelivery contains the captured immutable runtime/session view, materialized validated FrameSequence and exact byte reservation including CRLF/tags/framing. Ordinary deliveries are not rechecked against current state at append. Valid old captured output may finish after DEL; malformed contributions are never emitted. + +## State invariants and delivery ownership + +- ENABLE: stage/start/validate, publish new snapshot with existing session names unchanged, queue NEW; core cap-notify is never toggleable. +- DISABLE: publish withdrawal and all-session prune under administrative mutex, queue DEL, stop outside core locks. +- FAULT: same withdrawal path with FAILED; explicit administrative enable is required for recovery. Late faults belong to provider identity, even after re-enable. +- REQ: validate ordered candidate and reply size, replace one immutable set under administrative control, ACK; rejection changes nothing. +- Captured work is permitted to complete using old views, including after stop/DEL. Providers own safe synchronization of their resources during this overlap. +- #100 introduces contracts/safe dispatch and server-time; #101 completes ownership; #97 introduces runtime transitions/cap-notify; #102 completes runtime fault withdrawal/diagnostics and load evidence. diff --git a/specs/014-capability-plugin-spi/plan.md b/specs/014-capability-plugin-spi/plan.md new file mode 100644 index 0000000..8bf81a4 --- /dev/null +++ b/specs/014-capability-plugin-spi/plan.md @@ -0,0 +1,145 @@ +# Implementation Plan: Capability Plugin Ownership and Runtime Lifecycle + +**Branch**: `014-capability-plugin-spi` | **Date**: 2026-10-22 | **Spec**: [spec.md](spec.md) + +**Input**: Issues #98/#97, the original feature description and the user's three design-review decisions. Design only until user review; no implementation or commits in this workflow. + +## Summary + +The six capability modules own behavior through typed provider registrations. Core retains CAP negotiation/lifecycle, committed domain events, authorized delivery, reply layouts, budgets and serialization. Ordinary output uses one captured immutable runtime/session view. Under the existing administrative control mutex, a state change publishes a new RuntimeSnapshot and physically prunes ConnectionHandler.liveSessions, then queues DEL/NEW. Older captured/preparing output may finish after DEL. No strict output cutoff is promised. Core advertises cap-notify independently of plugins. + +[Research](research.md) records evidence/alternatives; [SPI](contracts/capability-spi.md), [lifecycle](contracts/runtime-lifecycle.md), [budgets](contracts/reply-budgets.md), [model](data-model.md) and [validation guide](quickstart.md) are the implementation contracts. + +## Technical Context + +**Language/Version**: Java 25, existing Gradle multi-module server, unreleased `0.1.0-SNAPSHOT`. + +**Primary Dependencies**: Existing ServiceLoader, java.util.concurrent, JUnit 6/AssertJ, SLF4J and jircd-protocol. No new runtime library or separate public API module. + +**Storage**: Immutable in-memory runtime/negotiation views, transient events/contributions, existing desired-state YAML. No persistent negotiation/history/account state. + +**Testing**: Tests first; SPI/provider unit contracts, socket lifecycle/failure/composition tests, irctest comparison and tagged loads. Existing integration files are unchanged in #100/#101. #97 updates exact-LS/provider-absence expectations for advertised cap-notify; no unrelated assertions weakened. + +**Target Platform / Project Type**: Existing JVM server with virtual-thread connections/writers and capability leaf modules; Linux/macOS/CI unchanged. + +**Performance Goals**: Existing delivery max <1 s and quiet-channel JOIN-storm p99 <500 ms/max <1 s/storm <15 s. #102 measures <=10% throughput regression versus recorded main baseline unless justified, and flags >10% latency/CPU/allocation increases. At 1,000 live sessions, administrative-mutex wait + snapshot/prune publication p99 <=100 ms, with notification enqueue/config I/O/provider callbacks/socket drain reported separately. + +**Constraints**: Command <=512 bytes including CRLF; independent tag-origin/section caps; exact SendQ including framing; no plugin callbacks under core locks or on writer drain; no socket I/O during publication; no global registry/session scan per recipient. Batch's final current-prerequisite choice is a short group-only administrative-mutex + writer-lock operation; ordinary output admission uses only its queue lock. + +**Scale/Scope**: Six providers; all live sessions including pre-registration; finite bounded groups. O(sum shared-channel memberships) event audience, O(applicable hooks + output bytes) per recipient, O(sessions × removed names) publication. Account/history/label/userhost features are mapped, not implemented. + +## Constitution Check + +These are design checks; implementation evidence is required per PR. + +| Principle/gate | Result | Implementation proof | +| --- | --- | --- | +| I: responsibility/dead code/public contracts | PASS | Typed hooks justified by six current providers; no new module/event bus; #101 deletes legacy behavior and documents public API migration | +| II: deterministic tests/no coverage decrease | PASS | Tests first, regression proof, retained concurrency cases with bounded cleanup; every PR build/static/coverage floors (85% line/75% branch) plus no aggregate decrease | +| III: path equivalence/real usage/breaking changes | PASS | Shared lifecycle and socket walkthrough; intentional cap-notify LS changes tested; migration guide/PR explanation under unreleased first-release version rule | +| IV: budgets/re-runnable measurement | PASS | Budgets declared above; each hot-path PR records relevant existing load results/comparison, #97 records repeatable publication timing from its socket fixture, #102 adds dedicated final load/JFR proof; >10% regressions require optimization or justification | +| Ordered merge gates | REQUIRED per PR | Scoped automated tests → full build/static checks → other-vendor review → real-use walkthrough/irctest as applicable → relevant measured performance before merge; rerun build after review fixes | + +Basic exception containment begins in #100. Final FAILED withdrawal/diagnostics and SC-005 load acceptance are completed in #102; earlier PRs do not claim the entire feature is done. No constitution waiver is requested or build result claimed here. + +## Delivery + +All four issues are sub-issues of #98. Every checklist task in [tasks.md](tasks.md) belongs to exactly one PR, in this order. Design docs land with #100. No implementation task runs before user review. + +| PR | Issue / scope | Tasks | Count | Exit checkpoint | +| --- | --- | --- | --- | --- | +| 1 | [#100](https://github.com/jircd/jircd-server/issues/100): SPI foundation + server-time + design docs | T001–T016 | 16 | Safe typed dispatch/byte accounting, server-time wire unchanged, legacy hooks retained for remaining providers; `./gradlew build` green | +| 2 | [#101](https://github.com/jircd/jircd-server/issues/101): message-tags → echo-message → away-notify → multi-prefix → batch; remove legacy hooks | T017–T041 | 25 | All six own behavior, provider absence removes it, no forbidden core branch, migration docs/irctest evidence; `./gradlew build` green | +| 3 | [#97](https://github.com/jircd/jircd-server/issues/97): runtime lifecycle, prune, negative REQ, advertised cap-notify | T042–T054 | 13 | Both admin paths/all client classes, new captures use current state, batch fallback/closure, exact-LS updates; `./gradlew build` green | +| 4 | [#102](https://github.com/jircd/jircd-server/issues/102): FAILED withdrawal, bounded diagnostics, dedicated load measurement | T055–T066 | 12 | Faults isolated over socket paths, <=10% throughput regression unless justified, SC-005 evidence/canonical docs; `./gradlew build` green | + +Tasks remain story-labelled within each PR; the PR boundary governs delivery. Each PR owns its own tests, documentation, review evidence and final build checkpoint. No task belongs to two PRs. Dedicated final load classes land in #102, while earlier hot-path PRs still run applicable existing repeatable loads/timings to meet their merge gates. + +## Project Structure + +### Documentation + +`specs/014-capability-plugin-spi/`: spec, plan, tasks, research, data-model, quickstart, three contracts and requirements checklist. Validation reports are future artifacts under its `validation/` directory, not generated by this revision. + +### Planned source changes + +```text +jircd-core/src/main/java/net/jircd/core/ + extension/{CapabilityExtension,AbstractCapabilityExtension,ServerContext,ExtensionRegistry}.java + extension/capability/ # descriptor/registrar/hooks/runtime/guard/layout contracts + capability/CapabilityNegotiator.java + session/ # generic events/member facts/delivery/prepared frames + session/{ClientSession,ConnectionHandler,SessionWriter,CapabilityTagRenderer,OutboundMessage}.java + session/{Channel,ChannelRegistry,DisconnectCleanup}.java + session/command/ # core event publishers/CAP/JOIN/NAMES/WHO, no feature-name branches + config/{ConfigurationLoader,ConfigurationReloader,HostmaskReplyBudget}.java +jircd-capabilities/{server-time,message-tags,echo-message,away-notify,multi-prefix,batch}/ + src/{main,test}/java/ +jircd-server/src/main/java/net/jircd/server/JircdServerApplication.java +jircd-server-extensions/admin/src/main/java/net/jircd/serverextensions/admin/ + {SajoinCommandHandler,ExtensionCommandHandler,RehashCommandHandler}.java +jircd-protocol/src/main/java/net/jircd/protocol/CapabilityNegotiationGrammar.java +jircd-integration-tests/src/test/java/net/jircd/integration/ +``` + +Keep SPI in core, preserve dependency direction and admin/cloak operational model. Use provider-owned staged command registration, not a rewrite of server extensions. ConnectionHandler.liveSessions is the all-session authority; NicknameRegistry omits unnamed sessions. + +## Architecture and responsibilities + +1. **Registration/guard**: Descriptor + CapabilityRegistrar stage typed hooks with owner, stage/slot, prerequisites, fallback and finite bounds. Validate ids/offers/key/slot conflicts and dependency DAG. Immutable tables are published as RuntimeSnapshot. Basic guard rejects/catches bad contributions in #100 and reports them through the existing sanitized operator-visible diagnostic path; #102 connects runtime faults to FAILED withdrawal and completes provider-level bounded diagnostics. +2. **Events**: Core commits state, captures permitted identity/audience facts, releases registry/channel locks, then invokes hooks. Implement away/join/part/quit/nick; retain accepted repeated-AWAY behavior, no-op JOIN/nick/duplicate cleanup silence and pre-departure peers. Hooks return intents, not socket writes. +3. **Delivery/tags**: Core authorizes targets once, merges sender-copy/ordinary/request-reply roles, deduplicates recipient+envelope and supplies one immutable captured view per recipient. Share pure validated tag contributions by envelope/hook identity; no callback lifetime tracking. Move semantic inbound tags/msgid/TAGMSG to message-tags; server-time is independent. +4. **Replies**: Core exposes consistent ordered held statuses and permitted identity facts. Ordinary output uses highest prefix; multi-prefix owns the status transform. Separate NAMES identity slot composes with future userhost-in-names. Fit complete fields/tokens after composition; generic bounds cover NAMES/WHO and all other existing layouts. No capability-shaped JOIN strings or booleans remain after #101. +5. **Groups**: Batch owns refs, syntax and tags. Core validates full finite alternatives and reserves complete frames including close. At final group-only admission, the existing administrative mutex protects the current prerequisite choice and queue append; missing batch/message-tags selects plain. No plugin call under locks. Previously admitted groups remain closed regardless of DEL timing. +6. **Lifecycle/capture**: Under the existing administrative control mutex, publish snapshot + physical prune, then queue DEL/NEW. CAP/session mutation and snapshot capture share that mutex. Ordinary preparation/append does not coordinate with later changes. Old captured work may follow DEL. Stop runs outside core locks; providers own safe overlap with their captured callbacks. Enable never restores prior negotiations. + +## Migration order and green checkpoints + +Use a temporary explicit bridge for unmigrated providers only. Exactly one behavior path per provider; never execute legacy and registered hooks together. #100 retains legacy methods for others; #101 removes both after migration. + +| PR / step | Capability | Required scoped checkpoint before next migration | +| --- | --- | --- | +| #100 / 1 | server-time | Pure tag registration/cache; Story2NegotiatedDeliveryTest + tag-byte/guard tests | +| #101 / 2 | message-tags | Inbound normalization/forwarding, msgid, owned TAGMSG + shared target gates; TagmsgCommandTest, Story2MessageIdTest, ConnectionAndCapPrecisionTest | +| #101 / 3 | echo-message | Sender role; direct/channel/self PRIVMSG/NOTICE/TAGMSG, no duplicates; DirectMessageGuaranteesTest/self-direct unit tests | +| #101 / 4 | away-notify | Event-owned interested-peer AWAY; dedup/cloak/core confirmations; AwayNotifyTest + generic event tests | +| #101 / 5 | multi-prefix | Member snapshots/render slots and bounds across NAMES/JOIN/SAJOIN/WHO; MultiPrefixTest/NamesByteBudgetTest | +| #101 / 6 | batch | Provider framing + atomic full queue operation/plain alternative; provider/group tests and socket probe | + +#100/#101 run the existing integration suite unchanged; core/provider unit fixtures may migrate APIs. #97 updates exact CAP LS and provider-absence assertions to include always-offered core cap-notify, without weakening unrelated behavior. #102 reuses those final expectations. Each PR ends with its own full green build. + +## Dependencies and future capabilities + +| Consumer | SPI seam | Separate future work | +| --- | --- | --- | +| batch | Owner + session message-tags prerequisite; missing prerequisite uses plain, no offer cascade | None for current finite groups | +| server-time | Independent pure outbound tag | No message-tags dependency | +| echo-message + labeled-response | Sender-copy/request-reply roles + immutable RequestContext, inbound/tag/group/completion stages, no cross-plugin calls | Label wire rules, no duplicate ACK when echo is response, no labels on unsolicited peers | +| account-notify #6 | Committed account-change event + interested peers/recipient negotiation | Account/auth state and real publisher; no dummy domain producer now | +| chathistory #7 | Provider-owned command, historical envelope ids/times, finite groups with batch prerequisites | Storage/query/auth/paging and history batch type; no unlimited streaming | +| userhost-in-names | Identity slot independent of status slot + byte bound | Provider wire behavior and `!ident@displayHost` bound/privacy policy | + +Hard dependencies are a validated DAG with atomic dependent withdrawal; session prerequisites only suppress behavior. Optional interactions compose explicit phases/roles/context. Future domain events/layouts may be added; core must not add capability-name behavior branches. + +## Validation and checklist coverage + +- Every hook type: valid/invalid/throwing result containment in #100; runtime FAILED/DEL/prune and sanitized rate-bounded diagnostics in #102. No rollback of committed core operations; cleanup continues after stop failure. +- Retained concurrency cases: CAP mutation versus prune, live-session insertion/attachment, a valid old capture finishing after DEL, and batch prerequisite choice/whole-group append. Use bounded coordination to prove these cases; do not assert suppression of old captured work or wait for callback completion. +- Reply/queue budgets: NAMES whole tokens + 366, WHO flags/fitted text, independent escaped tag origins, complete group reservation/close and recipient-only overflow cleanup. +- Invalid reload/REQ/dependency/size requests change nothing; accepted known removals and exact echoes/sticky 302 behavior are tested. +- No per-recipient global scans or callbacks in domain/admin/queue critical sections. Measure mutex hold/wait and notification append separately; each hot-path PR records existing loads, #102 supplies dedicated final measurement. +- Each PR updates public Javadoc/docs it changes, passes `.github/REVIEW_CHECKLIST.md`/other-vendor review and ends with build green. Canonical lifecycle docs change in #97 and gain final failure/measurement details in #102. + +## Complexity Tracking + +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 + +- Captured output may follow DEL, and stop may overlap captured callbacks. This is the accepted rule; clients/tests cannot treat DEL as a strict output barrier. Record any irctest conformance consequence openly. +- Providers must make captured callbacks and start/stop thread-safe. A late fault belongs to the provider identity and may withdraw a newly re-enabled provider; core does not forcefully stop blocked trusted code. +- The administrative mutex visits all live sessions and also protects short captures/group selection. Measure contention/prune/notification append separately; slow sockets and plugin callbacks cannot run under it. +- Pre-materialized tag/group alternatives may increase allocation/CPU. Share immutable work, retain byte guards and prove SC-005 rather than assuming cheap dispatch. +- cap-notify changes exact LS/absence expectations in #97, including all-plugins-absent LS. Its core ownership and sticky 302 semantics must not depend on provider discovery. +- Existing unreleased public APIs/unit harnesses change in #100/#101. Keep the migration guide synchronized; external binary adapters remain outside scope. +- The local irctest harness already has user modifications. Freeze/hash its state and use identical selection/config for both runs; do not hide new failures/skips. diff --git a/specs/014-capability-plugin-spi/quickstart.md b/specs/014-capability-plugin-spi/quickstart.md new file mode 100644 index 0000000..ccca282 --- /dev/null +++ b/specs/014-capability-plugin-spi/quickstart.md @@ -0,0 +1,92 @@ +# Quickstart: Validate the Four Feature 014 PRs + +Future implementation validation only; no command below was run during this design revision. User review is required before implementation. Use Git worktrees, JDK 25 and existing Gradle toolchains. Never read `~/.gradle/gradle.properties`, stop shared Gradle daemons or commit credentials. A coordinator handles any required worktree/git metadata operations; do not bypass a failed mandatory step. + +## Baseline and evidence ownership + +Record baseline server revision `5912b68`, machine/JDK/JVM, existing test/static/coverage results and applicable load metrics before changing code. If comparison is against a later main, record that exact revision and use it consistently. Keep baseline distribution/code in an approved project worktree, not a temporary code workspace. + +Record existing integration source hashes. #100/#101 must keep those sources/assertions unchanged. #97 updates only expectations deliberately affected by advertised core cap-notify (including exact LS/provider absence); record that narrow diff rather than treating all test edits as forbidden. #102 must preserve those expectations without unrelated weakening. + +Future results live under `specs/014-capability-plugin-spi/validation/pr-100/`, `pr-101/`, `pr-97/` and `pr-102/`. Each PR records test commands, actual reports, fixture changes, review, relevant measurements and its own final green build. Design docs land in #100. + +## PR 1 — #100 foundation and server-time + +Write typed-registration, result guard, reply-bound and complete frame queue tests first. Exercise every hook shape with valid/throwing/null/invalid output; basic containment/fallback must protect committed core state and unrelated providers. Retain sanitized operator-visible error reporting through the existing diagnostic path; full runtime FAILED withdrawal/provider-level bounded diagnostic acceptance belongs to #102 after #97. + +Stage typed registrations/immutable tables, generic budgets/frames and provider-owned services. Keep legacy contributeTags/includeSenderInFanOut for unmigrated providers and prove exactly one active path per provider. Migrate server-time and verify exact timestamp/pure immutable cache behavior, independent of message-tags. + +```sh +./gradlew :jircd-core:test --tests '*Capability*' --tests '*SessionWriter*' +./gradlew :jircd-capabilities:server-time:test +./gradlew :jircd-integration-tests:test --tests 'net.jircd.integration.Story2NegotiatedDeliveryTest' +``` + +Run applicable existing tagged delivery/JOIN loads, record comparison, review public contracts, then finish with `./gradlew build` green. Record proof in `validation/pr-100/`. No notification or cap-notify LS change is claimed by this PR. + +## PR 2 — #101 remaining providers + +Migrate one at a time, writing module tests first and recording a green checkpoint before moving on: + +```sh +./gradlew :jircd-integration-tests:test --tests 'net.jircd.integration.TagmsgCommandTest' --tests 'net.jircd.integration.Story2MessageIdTest' --tests 'net.jircd.integration.ConnectionAndCapPrecisionTest' +./gradlew :jircd-integration-tests:test --tests 'net.jircd.integration.DirectMessageGuaranteesTest' +./gradlew :jircd-integration-tests:test --tests 'net.jircd.integration.AwayNotifyTest' +./gradlew :jircd-integration-tests:test --tests 'net.jircd.integration.MultiPrefixTest' --tests 'net.jircd.integration.NamesByteBudgetTest' +./gradlew :jircd-capabilities:batch:test +``` + +These correspond to message-tags, echo-message, away-notify, multi-prefix and batch. Also run each provider's own tests and relevant core tests. Verify all NAMES/JOIN/SAJOIN/WHO routes, client-only tags from an unnegotiated sender where current behavior permits them, stable msgid/time and no duplicate self-target echoes. Add successful/no-op/rejected event coverage for away/join/part/quit/nick. + +For module absence use an isolated discovery/runtime catalog or test classpath, omit the missing id from test-owned desired config, never delete installed JARs. Assert absent offer AND behavior and unaffected core/other providers. #97 later extends this matrix so LS retains core cap-notify even without all providers. + +Remove both legacy hooks/adapters only after the last migration; audit every inventory site/literal alias in research. Run synthetic field/request/group composition probes, generic budget boundary tests and the full frozen-harness irctest comparison below. Record migration/API/docs/review/performance evidence under `validation/pr-101/`, then finish with `./gradlew build` green. + +## PR 3 — #97 runtime lifecycle + +Update exact CAP LS tests to include cap-notify as always-offered core infrastructure. A no-provider runtime still advertises it. Explicit REQ is governed by the offered set; test normal positive acceptance, sticky >=302 support, legacy removal and implicit-only omission from LIST. + +Use TestServer-owned generated YAML/credentials, never local `jircd-server/jircd.yaml`. Matrix: {EXTENSION, REHASH} × {explicit legacy cap-notify, implicit CAP LS >=302, legacy no-notify} × {multi-prefix, message-tags}. New test names become runnable when implemented: + +```sh +./gradlew :jircd-integration-tests:test --tests '*CapabilityLifecycleTest' --tests '*CapabilityGroupLifecycleTest' --tests '*CapabilityProviderAbsenceTest' +./gradlew :jircd-core:test --tests '*CapabilityLifecycleNegotiationTest' +``` + +Walkthrough: + +1. Register an administrator and the three peer classes; negotiate target capabilities and establish +o/+v/NAMES/WHO/message behavior. Explicit cap-notify must first be visible in LS. +2. Disable through EXTENSION, or edit only test-owned YAML then REHASH. Under the administrative mutex, snapshot publication and pruning of all live sessions occur together, then DEL is queued. Check exact target/list, including eligible pre-nick `*` and a never-held observer. Legacy no-notify gets no DEL. +3. CAP LS excludes the withdrawn provider but includes cap-notify. LIST excludes pruned names. Commands initiated after the completed administrative operation capture new state and return ordinary prefix/tag behavior. `CAP REQ :-name` ACKs for already-pruned known names; held-but-unoffered is a separate controlled setup. Unknown/mixed/unavailable/empty/oversize requests reject atomically with exact echo/417. +4. Enable and observe NEW, with LIST still lacking the name until an explicit new request. Repeat/no-op toggle and invalid reload do not emit differences or mutate state. Use bounded PING/PONG barriers for absence checks. +5. Test concurrent CAP/session insertion/attachment against one all-session prune. Capture an old output view, publish disable and let that work finish: output after DEL is allowed. Assert the next capture uses current state; do not assert a strict cutoff or lifecycle retry. +6. For a finite group, prove all frames/close are admitted together. An already-open group's close survives (unless the connection terminates), without asserting a general ordering relative to DEL. Hold another group before final admission, disable a prerequisite, then admit it and assert entirely plain members. Exercise this through a real registered test command, not a private writer shortcut. +7. Verify stop is invoked outside core locks and provider callbacks can safely overlap it. Use bounded barriers only for retained state/group semantics and always release/close in finally blocks. + +Record a repeatable 1,000-session publication timing sample using the socket fixture plus applicable existing tagged loads before this PR merges. The declared state-publication budget is <=100 ms p99 for administrative-mutex wait + snapshot/prune. Measure notification enqueue and stop/drain separately. #102 adds the dedicated load classes/final controlled comparison. Update canonical lifecycle contracts and review, then finish #97 with `./gradlew build` green and reports under `validation/pr-97/`. + +## PR 4 — #102 FAILED withdrawal and measurements + +Inject exceptions/null/illegal/oversized/unauthorized results for every hook category, using real sockets for hooks affecting wire output. Assert FAILED state and the same snapshot/prune/DEL path, bounded sanitized diagnostics, committed core outcomes, complete fallback and continued unrelated delivery. Test partial start/cleanup/stop failure, explicit recovery, hard-dependency cascades and a late callback fault after re-enable attributed to provider identity. Do not wait for active callbacks as a lifecycle precondition. + +At maximum valid server/nick/channel/ident/host lengths verify combined status + synthetic identity decorations, WHO fitted realname, NAMES whole tokens/366 and escaped client/server tags. Assert exact serialized bytes, origin/total caps and complete group SendQ reservation. Impossible startup/reload/enable bounds reject without state mutation. Null/lying bounds fault the provider; valid text fitting is not a fault. + +Run dedicated tagged CapabilityFanoutLoadTest (10/100/1,000 mixed recipients) and CapabilityLifecycleLoadTest (1,000 sessions) via `./gradlew loadTest`. Warm up and measure at least five identical runs per baseline/candidate, with same-machine/JVM/config/payload/negotiation/rate settings. Capture JFR CPU/allocations and report raw paths, medians, p99/max and throughput. Require <=10% message throughput regression unless justified; flag >10% latency/CPU/allocation increases. Preserve existing delivery <1 s and JOIN-storm quiet p99 <500 ms/max <1 s/storm <15 s. Compare publication wait/hold separately from notification enqueue/callbacks/drain; old output completion is not part of that budget. + +Rerun full irctest with final cap-notify expectations, update canonical failure policy/README, perform full socket usage/review and coverage checks. Record under `validation/pr-102/`. After review fixes, finish with `./gradlew build` green. Every PR must meet build's Spotless/SpotBugs/PMD/coverage floors (85% line/75% branch) with no aggregate coverage decrease. + +## Frozen irctest comparison + +The local harness is `/Users/gcastro/Projects/jircd/irctest`, recorded HEAD `76b91582ca8f8cf6528f750c10f104e0df45865a`, with existing user modifications to controller and requirements. Preserve them. Hash those files and record dependency environment/controller config; HEAD alone does not pin it. The current controller declares the three Story2 capabilities; dedicated socket tests cover the added provider/lifecycle behavior too. + +Use exactly the same frozen harness state for baseline/candidate, without new skips, reduced advertised features or user-file resets/stashes. A coordinator provisions any required harness worktree and reviewed controller patch. Set JIRCD_JAVA_HOME to the same JDK 25, build each server with `./gradlew :jircd-server:installDist`, and run from that harness with a revision-specific launcher/report path, for example in #101: + +```sh +JIRCD_LAUNCHER=/Users/gcastro/Projects/jircd/jircd-server/.worktrees/014-capability-plugin-spi/jircd-server/build/install/jircd-server/bin/jircd-server python3 -m pytest --controller irctest.controllers.jircd --timeout=60 --timeout-method=signal --junitxml=/Users/gcastro/Projects/jircd/jircd-server/.worktrees/014-capability-plugin-spi/specs/014-capability-plugin-spi/validation/pr-101/irctest-candidate.xml +``` + +Use baseline/candidate result paths in each PR's directory and compare case ids/pass/fail/skip sets. SC-004 requires zero new failures/skips; record unrelated baseline failures. The accepted rule allowing captured work after DEL differs from the stronger protocol cutoff interpretation: if a conformance test exposes that tradeoff, report it as an unresolved observed failure for user review, not a skip or invented pass. No such outcome is claimed in this design phase. + +## Per-PR review and stop rule + +Follow `.github/REVIEW_CHECKLIST.md` and obtain the required other-vendor review before each implementation PR. On trusted branches use the documented SonarCloud gate when credentials are available through Gradle, without reading its properties file; otherwise record the repository-permitted no-token skip. Fix real findings, rerun required checks, and end that PR's task group with `./gradlew build` green. Final #102 results map all FR/SC acceptance evidence. No commit, push, PR creation or implementation is authorized by this document revision. diff --git a/specs/014-capability-plugin-spi/research.md b/specs/014-capability-plugin-spi/research.md new file mode 100644 index 0000000..03a209c --- /dev/null +++ b/specs/014-capability-plugin-spi/research.md @@ -0,0 +1,96 @@ +# Research: Capability Plugin SPI + +All decisions below are proposals for implementation after review, not descriptions of changes already made. + +## Evidence and scope + +- Read `gh issue view 98` and `gh issue view 97`; #98 requires actual behavior ownership and unchanged integration/irctest results; #97 requires atomic pruning, DEL/NEW and safe negative REQ. The later user decision intentionally updates exact CAP LS expectations in #97. +- Ran CodeGraph on CapabilityExtension, AbstractCapabilityExtension, ExtensionRegistry, CapabilityNegotiator, CapabilityTagRenderer, SessionWriter batching, AWAY fan-out, Channel.memberListing/statusPrefixes, WhoCommandHandler, ServerExtension, CommandRegistrar, ServerContext and HostmaskReplyBudget. CodeGraph warned its index belongs to the main checkout. Used targeted current-worktree source reads/searches to verify findings; did not rebuild the index. Read-only inventory assistance was independently verified, including correcting its claim that negotiated sets were already immutable. +- Worktree HEAD is `5912b68`; `ClientSession` swaps an AtomicReference but exposes mutable concurrent sets. Current API therefore does not enforce a lifecycle-wide atomic transition. +- Reviewed constitution I–IV and gates, `.github/REVIEW_CHECKLIST.md`, original configuration “Path equivalence”, CAP contract and Extension model, plus feature 011's plan and away/batch contract. Original feature documents have historical omissions; this feature's contracts explicitly amend them and tasks update canonical documentation at implementation time. + +## Complete current core behavior inventory + +Paths are relative to `jircd-core/src/main/java/net/jircd/core/` unless specified. A scoped search covered both `CapabilityName` references and literal existing capability names, plus generic hooks and bootstrap wiring. + +| Location | Existing behavior to extract | Destination/seam | +| --- | --- | --- | +| `session/CapabilityTagRenderer.java:35–75` | MESSAGE_TAGS forwards client tags; scans enabled providers and captures deferred tag callbacks | message-tags owns forwarding/msgid; generic compiled tag dispatcher | +| `session/command/TagmsgCommandHandler.java:97` | MESSAGE_TAGS recipient filter; tag-only command and fan-out | message-tags owns command; shared core delivery service retains send gates | +| `session/command/AwayCommandHandler.java:75–91` | AWAY_NOTIFY filters deduplicated shared-channel peers and builds AWAY | away-notify committed-event handler | +| `session/SessionWriter.java:174–207` | BATCH and MESSAGE_TAGS gates, ref generation, BATCH frames and member tag | batch output shaper; writer handles generic validated frames | +| `session/ChannelRegistry.java:110–112` | MULTI_PREFIX in JOIN result listing | raw member snapshots, render through reply service after locks | +| `session/command/JoinCommandHandler.java:244–247` | MULTI_PREFIX in NAMES; used by JOIN/NAMES/SAJOIN | generic member-field renderer | +| `session/command/WhoCommandHandler.java:149–152` | MULTI_PREFIX in channel WHO status flags | same member-status slot | +| `session/Channel.java:117–158` | Boolean multiPrefix overloads implement highest versus all statuses without a CapabilityName reference | core exposes consistent ordered privileges, keeps ordinary highest-prefix default; multi-prefix selects all | +| `session/command/MessageCommandHandler.java:134–199` | Generic sender-inclusion scan and echo-specific assumptions; shared TAGMSG routing helpers package-private | generic recipient policy and public narrow authorized-delivery service | +| `session/OutboundMessage.java:111–115` | Memoized contribution bytes call providers; renderer later calls them again | immutable validated provider contribution cache by envelope/hook identity | +| `jircd-server/.../JircdServerApplication.java:310` | Hard-wires core TAGMSG handler independently of provider | message-tags stages its own command registration | + +Core `capability/CapabilityNegotiator` and `session/command/CapCommandHandler` legitimately handle CAP. They currently validate removal against offers, ignore LS version, expose a validate/apply race and have no NEW/DEL. Protocol `CapabilityName` constants and BATCH/TAGMSG token recognition may remain wire vocabulary; no core behavior may branch on these six names or aliases. `server-time` already owns its tag; echo-message owns its include decision but core scans every enabled extension and the plugin reads a live session. + +## Decision 1 — Typed, staged registrations + +**Decision**: CapabilityExtension registers typed hooks through CapabilityRegistrar alongside the existing Extension lifecycle. Publish immutable descriptor/hook tables. ServerContext supplies narrow event/reply/delivery services; command registrations are provider-owned. Retain admin/cloak's ServerExtension/CommandRegistrar behavior. + +**Rationale**: Actual behavior ownership like admin, with central negotiation prerequisites, composition, validation and failure containment. No reflection/discovery on message paths. + +**Alternatives**: More unrelated default methods would leave registry scans and implicit composition. A general event bus or separate public API artifact adds machinery without a present consumer. Keep public SPI under core's extension package. + +## Decision 2 — Snapshot capture and simple administrative publication + +**Decision**: Use the existing administrative control mutex to publish one immutable RuntimeSnapshot and physically prune every live session in ConnectionHandler.liveSessions in one step, then queue DEL/NEW. Capture runtime + relevant immutable session negotiation under that mutex, release it and evaluate hooks outside core locks. Ordinary prepared output uses its captured view without lifecycle admission checks. Already captured/preparing work may follow DEL; later captures use new state. Stop runs outside core locks and may overlap captured callbacks. + +**Rationale**: This is the user's chosen lifecycle rule. It meets atomic state/prune requirements without promising a strict output cutoff or tracking the lifetime of each callback. Provider start/stop/callbacks must be thread-safe; late valid results are allowed, malformed ones are not. A late fault is attributed to the provider identity and may withdraw a provider that was enabled again in the meantime. + +**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. + +**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 + +**Decision**: CAP LS advertises core-owned cap-notify, including when no capability provider is installed. Explicit positive REQ uses the ordinary offered-name rule. >=302 implies sticky notification support; explicit negotiation appears in LIST, implicit-only support need not. >=302 clients cannot remove support, older explicit clients may. Enable never restores pruned memberships. + +**Compatibility**: #100/#101 keep existing integration tests unchanged. #97 changes exact CAP LS expectations (including Story2CapabilityListTest) and provider-absence expectations to retain cap-notify. No unrelated assertion weakening. Do not retain a special acceptance rule for unadvertised positive requests. + +Negative REQ accepts a held name regardless of current offers and a discovered already-pruned name as an acknowledged no-op. Unknown unheld removal remains NAK. Preserve raw token order/repeats and check reply size before mutation under administrative control. + +**Sources**: [IRCv3 CAP](https://ircv3.github.io/specs/extensions/capability-negotiation.html) defines version persistence, notifications and explicit cap-notify negotiation. [IRCv3 batch](https://ircv3.net/specs/extensions/batch.html) requires closing started batches. The repository's stricter batch/message-tags fallback is preserved. Allowing captured output after DEL is an explicit user-selected conformance tradeoff; record any observed test consequence instead of concealing it. + +**Alternatives**: Hiding cap-notify to preserve exact old LS tests was rejected by the user. Making it a toggleable plugin incorrectly couples CAP infrastructure to a capability module. + +## Decision 4 — Availability dependencies and behavioral prerequisites + +**Decision**: Hard provider dependencies form a validated DAG. Missing prerequisites reject enable; withdrawal of a prerequisite removes transitive hard dependents in the same publication. Explicit desired enable is needed to restore them. Session-level prerequisites suppress a hook with declared default, without withdrawing the owner's offer or negotiated name. Batch uses a session message-tags prerequisite: batch-only negotiation ACKs, members are plain, and a later message-tags request can make still-held batch effective. server-time remains independent. + +**Rationale**: Preserves feature 011's semantics and permits real future dependencies. Validate the entire candidate reload before mutation; start-code failure is an isolated FAILED outcome, not a configuration validation rollback. Optional interactions use request context/delivery roles rather than cyclic plugin calls. + +## Decision 5 — Contain first, complete FAILED withdrawal in #102 + +**Decision**: #100 validates registrations/results and catches ordinary hook RuntimeException/LinkageError so partial or malformed contributions cannot damage core state or other providers, retaining sanitized operator-visible reporting through the existing diagnostic path. #102 completes provider FAILED withdrawal through #97's state/prune/DEL path and bounded, sanitized diagnostics. Fallback applies only to the failed contribution; valid captured work is not restarted. Committed AWAY/JOIN/PART/QUIT/NICK state is never rolled back. Stop failure cannot republish state; no automatic recovery. + +**Rationale**: Each PR remains usable, while fault-to-lifecycle integration lands only after that lifecycle exists. This matches #100's basic exception containment acceptance and the user's allocation of full fault isolation to #102. + +**Alternatives**: Silent faults or propagation through fan-out would break existing isolation. Core does not forcibly terminate blocked trusted code or wait for callbacks; providers own synchronization of resources needed by captured work. VM-fatal errors remain outside ordinary plugin fault handling. + +## Decision 6 — Typed fields and deterministic byte bounds + +**Decision**: Separate status and NAMES identity slots; one replacement owner per slot, independent slots compose in schema order. Tags have owned keys/namespaces and distinct client/server origins. Grouping is outermost after field/tag composition. No arbitrary rewriting of numeric arity. + +Current safe NAMES bound is `S+2N+C+15` (two status symbols); end `S+N+C+29`; WHO reserves four flags. Generic ReplyLayout + installed-provider bounds retain those safe configurations. Reserve all discovered compatible decorations so later enable does not invalidate accepted lengths. Validate active composition at enable and guard actual bytes at runtime. Full formulas, tag limits and SendQ rules remain in the budget contract. + +## Decision 7 — Four PRs with independent green builds + +**Decision**: Deliver #100 foundation/server-time/docs, then #101 five migrations/legacy removal, then #97 runtime lifecycle/cap-notify advertisement, then #102 fault withdrawal/diagnostics/dedicated load measurement. Every task has one PR owner; each PR ends in `./gradlew build` green and a documented checkpoint. Run scoped tests after each capability migration. #101 runs the frozen-harness irctest comparison; #102 reruns after final lifecycle/fault behavior. + +**Rationale**: Smaller reviewable increments with clear issue ownership and no intermediate claim that all final-feature criteria are complete. Public migration docs accompany changes, and exact-LS changes are localized to #97. + +## Decision 8 — Measure cost under the simpler rule + +**Decision**: Compile hook tables on changes, reuse immutable session selections and memoize validated pure tags by envelope/hook identity solely for computation sharing. Ordinary per-recipient work is O(applicable hooks + output bytes); shared-channel event audiences are collected once. Publication is O(live sessions × withdrawn names); capture and group selection use short existing-mutex sections. + +Dedicated #102 loads compare against the recorded main baseline (`5912b68` for this design; record any later main comparison used). Preserve delivery <1 s and JOIN-storm quiet p99 <500 ms/max <1 s/storm <15 s. Require <=10% throughput regression unless justified, and review >10% latency/CPU/allocation increases. At 1,000 sessions, mutex wait + snapshot/prune publication p99 <=100 ms; measure notification enqueue, provider callbacks/config I/O and socket drain separately. Do not measure completion of old captured output as part of state publication. + +## Resolved decisions and exclusions + +The user's three decisions are incorporated without unresolved design questions. No implementation, build, SonarCloud upload or irctest run was performed during this revision. The local irctest harness was already dirty; reproducible comparisons must preserve and hash that state. Dynamic JAR unload, untrusted sandboxing, persistent history/account features and unbounded output streaming remain outside scope. diff --git a/specs/014-capability-plugin-spi/spec.md b/specs/014-capability-plugin-spi/spec.md new file mode 100644 index 0000000..0b5ec0d --- /dev/null +++ b/specs/014-capability-plugin-spi/spec.md @@ -0,0 +1,124 @@ +# Feature Specification: Capability Plugin Ownership and Runtime Lifecycle + +**Feature Branch**: `014-capability-plugin-spi` + +**Created**: 2026-10-22 + +**Status**: Draft — ready for design review; implementation requires user review. + +**Input**: Make the existing capability modules own their behavior through a real plugin SPI, including runtime cancellation and capability notifications. Covers [#98](https://github.com/jircd/jircd-server/issues/98) and [#97](https://github.com/jircd/jircd-server/issues/97). + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Capabilities own their behavior (Priority: P1) + +A capability author implements and removes a capability in its own module. Server maintainers provide reusable extension points rather than adding capability-name decisions to the server for each feature. Clients continue to see the same messages when capabilities remain enabled. + +**Why this priority**: The existing marker modules do not achieve the intended plugin boundary. + +**Independent Test**: Exercise each existing capability, then run with its provider absent. Its offer and behavior disappear together, while ordinary IRC and other capabilities continue working. Run existing integration tests unchanged through the provider migrations; #97 updates exact CAP LS expectations for advertised core cap-notify. + +**Acceptance Scenarios**: + +1. **Given** clients with different negotiated capabilities, **When** they exchange PRIVMSG, NOTICE and TAGMSG, **Then** client tags, message identifiers, server time, echo recipients and self-delivery retain their existing semantics. +2. **Given** peers sharing several channels, **When** a user sets, changes or clears AWAY, **Then** away-notify recipients receive one cloak-aware notification each, with unchanged visibility and sender confirmations. +3. **Given** a member who is both operator and voiced, **When** clients request NAMES or channel WHO, including JOIN/SAJOIN listings, **Then** only multi-prefix clients receive every status in PREFIX order; ordinary clients receive the highest status. +4. **Given** related replies, **When** the recipient has both batch and message-tags, **Then** existing BATCH framing and member tags are preserved; missing either capability produces existing plain delivery. +5. **Given** any of the six existing capability providers is absent, **When** the server starts and clients use related commands, **Then** that capability is neither advertised nor executed, and core retains no dormant implementation of it. Core cap-notify remains advertised independently of every provider. + +### User Story 2 - Runtime changes cancel negotiated behavior (Priority: P1) + +An administrator disables or enables a capability through EXTENSION or REHASH. Connected clients obtain consistent state, receive availability notifications when eligible, and can safely negotiate again. + +**Why this priority**: #97 violates the existing configuration path-equivalence contract. + +**Independent Test**: Over real sockets, negotiate multi-prefix and message-tags, disable each through both paths, verify DEL, LS, LIST and subsequent NAMES/WHO/messages, then enable and verify NEW and explicit renegotiation. Repeat with explicit cap-notify, implicit CAP 302 and an older client without notifications. + +**Acceptance Scenarios**: + +1. **Given** sessions holding a capability, **When** it becomes disabled, **Then** its offer, executable behavior and every session's negotiated membership change as one observable transition. Subsequent CAP LIST excludes it. +2. **Given** explicit cap-notify or CAP LS 302-or-higher clients, **When** the offered set changes, **Then** they receive DEL for removals and NEW for additions even if they never held those capabilities. Other clients receive neither. +3. **Given** a held capability no longer offered, **When** the client requests `CAP REQ :-name`, **Then** removal succeeds. Removal of a discovered capability already pruned by disable is an acknowledged no-op. A mixed request containing an unavailable addition fails atomically. +4. **Given** a previously negotiated capability, **When** it is enabled again, **Then** eligible clients receive NEW, but behavior resumes only after a new positive request. +5. **Given** output captured or being prepared when disable publishes, **When** that work completes, **Then** it may still be emitted after DEL. An operation whose runtime/session view is captured after publication uses the new snapshot; earlier captured work may finish preparation later. A batch group admitted together keeps its close; a group still awaiting admission when batch or message-tags becomes ineffective is sent plain. +6. **Given** an invalid reload or repeated no-op toggle, **When** submitted, **Then** invalid configuration changes nothing and reports an actionable error, and an idempotent toggle emits no duplicate notifications. + +### User Story 3 - Extension failures and growth remain bounded (Priority: P2) + +Operators can run capabilities without malformed output, plugin errors or large fan-outs damaging unrelated sessions. Authors can add related behavior using the documented extension contract. + +**Why this priority**: Moving behavior out of core must preserve failure isolation, wire limits and measured delivery budgets. + +**Independent Test**: Use test-only providers that throw, return invalid data, compose reply decorations and require other capabilities. Prove failure state, cleanup, reply sizes and unaffected peer delivery; measure fan-out and concurrent reload against baseline. + +**Acceptance Scenarios**: + +1. **Given** a throwing or invalid-data provider, **When** evaluated, **Then** it becomes FAILED and is withdrawn using the disable lifecycle, a bounded diagnostic identifies provider and hook, and core operations and unrelated providers continue. +2. **Given** maximum configured lengths and compatible reply decorations, **When** configuration is validated or a provider enabled, **Then** impossible fixed reply fields are rejected before publication; variable text and NAMES lists are fitted without breaking tokens or silently losing replies. +3. **Given** unavailable or unnegotiated behavioral prerequisites, **When** a hook would run, **Then** its declared fallback applies. Batch alone remains negotiable and delivers plain members until message-tags is effective. +4. **Given** future extensions, **When** mapped to the SPI, **Then** account-notify can consume account changes, userhost-in-names can compose with status rendering, and chathistory/labeled-response can use grouping and request context without capability-name branches in core. These features are not implemented here. + +### Edge Cases + +- REQ racing disable, connection admission, LIST, nickname changes or another administrative transition; unregistered sessions. +- Lower/omitted LS version after 302; cap-notify removal by a 302 client versus a legacy client. +- Unknown versus discovered-but-disabled removals; repeated and conflicting REQ tokens processed in order; oversize ACK without mutation. +- A render captured before publication finishing after DEL; stalled socket with an admitted batch; unadmitted group losing a prerequisite; SendQ overflow while queuing DEL/NEW. +- Hook failure after core membership/away state committed; stop throwing; partial start/registration failure; callbacks finishing after provider stop or re-enable. +- Null/illegal tags, forged server tags, duplicate tag/field ownership, false byte bounds, invalid groups and oversized member tokens. +- Peers changing during audience collection; shared-channel duplicates; self-target messages; sender already in recipients. +- Missing prerequisites, dependency cycles, duplicate offers and incompatible reply budgets on enable/reload. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: Each existing capability (message-tags, server-time, echo-message, away-notify, batch, multi-prefix) MUST own its behavior in its module. Core MUST retain only negotiation/lifecycle and generic protocol, session, channel, delivery and extension services, with no behavior decisions keyed to those names. +- **FR-002**: The plugin contract MUST support committed away/join/part/quit/nick events, interested-peer delivery, reply-field rendering, related-output grouping, inbound tags and outbound contributions. It MUST explain future account events and request context without implementing account/history features now. +- **FR-003**: Provider absence MUST remove both offer and behavior. Core authorization, visibility, identity protection, state ownership, reply ordering and cleanup MUST remain authoritative. +- **FR-004**: Stable enabled/negotiated configurations MUST preserve existing wire behavior: tag origins/identifiers, echo deduplication, AWAY visibility, prefix order and batch fallback/framing. Existing integration tests MUST pass unchanged in #100/#101; #97 MUST update exact CAP LS expectations to include core cap-notify. No unrelated assertions may be weakened. +- **FR-005**: Disable/enable/failure MUST publish a new immutable RuntimeSnapshot and prune withdrawn names from every live session in ConnectionHandler.liveSessions in one step under the existing administrative control mutex, then queue DEL/NEW. Capture and session insertion/removal MUST use that mutex so new work sees coherent state; already captured work may finish using its old view. +- **FR-006**: EXTENSION and validated REHASH MUST share one lifecycle. Validation errors MUST preserve active configuration/state. Plugin start/runtime failures MUST be isolated as FAILED. Repeated requests MUST be idempotent. +- **FR-007**: Explicit cap-notify and CAP 302-or-higher clients MUST receive correctly targeted DEL/NEW for offered-set changes, queued after state publication in transition order, within protocol/SendQ limits and without duplicate names. Other clients MUST receive neither. +- **FR-008**: Notification support MUST persist once CAP 302 is indicated and MUST NOT be removable by that client. Older clients MAY enable/remove cap-notify explicitly. Core-owned cap-notify MUST appear in CAP LS even without capability plugins. Implicit-only support is omitted from LIST; explicit negotiation appears there. Positive REQ follows the offered catalog without a special exception. +- **FR-009**: REQ MUST remain all-or-nothing, echo the exact request and validate reply size before mutation. Removal MUST succeed for a held capability regardless of offers and for a discovered capability already pruned. Unknown unheld removal, unavailable addition and empty requests MUST reject without mutation. +- **FR-010**: Enable MUST send NEW without restoring prior negotiated memberships. Prerequisites MUST be declared and evaluated generically, distinguishing provider availability, session prerequisites and optional interactions; batch's existing plain fallback MUST remain. +- **FR-011**: Ordinary output MUST use its captured immutable runtime/session view without an admission recheck or retry. Old captured/preparing work MAY follow DEL. Batch groups MUST be admitted as complete queue operations including close, with plain fallback if their required capabilities are ineffective at group admission. Provider stop MUST run outside core locks and be safe with overlapping captured callbacks. +- **FR-012**: Every hook MUST define exception/invalid-result policy, including start/stop and cleanup. A plugin fault MUST NOT roll back committed core operations, publish partial contributions, leak resources or stop unrelated delivery. Diagnostics MUST be rate bounded and omit contents, credentials and arbitrary exception payloads. +- **FR-013**: Reply-length contributions MUST declare verifiable byte bounds. Startup/reload/enable MUST account for every compatible NAMES/WHO decoration and the full 512-byte command section. Tags MUST retain independent origin/total limits; SendQ MUST include framing/tag bytes. +- **FR-014**: Hot-path dispatch MUST avoid global registry/session scans per recipient and plugin calls under channel/output-queue locks, with bounded work/memory. Re-runnable measurements MUST cover mixed recipients, large fan-out, rendering, grouping and runtime changes. +- **FR-015**: Migration MUST proceed one capability at a time with relevant tests green after each. Public SPI changes MUST document a migration path consistent with unreleased-project rules. +- **FR-016**: Contracts MUST document the SPI, failure policy, dependencies and lifecycle, including explicit amendments to existing configuration/CAP contracts. + +### Key Entities + +- **Capability provider**: Discoverable behavior owner with identity, one offer, hooks, prerequisites, reply bounds and lifecycle state. +- **Capability runtime view**: Immutable offered providers, hook tables and effective session memberships captured under administrative control. +- **Session negotiation**: Explicit capabilities, remembered CAP version and notification eligibility. +- **Committed event**: Successful core operation, permitted audience and pre/post identity for correct notifications. +- **Reply contribution**: Validated, bounded adjustment to an allowed field or tag, composed with compatible providers. +- **Delivery group**: Related logical messages, optional request correlation and an ordered framing plan admitted to one recipient together. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: All six capabilities pass the existing integration scenarios, with only the planned CAP LS expectation updates in #97; removing each eliminates its offer and behavior while core cap-notify remains. Zero capability-specific core behavior branches remain outside negotiation/lifecycle. +- **SC-002**: Real-socket tests pass for both administrative paths, all three client classes, disable/re-enable and cancellation. Every post-transition LIST and output whose view is captured after publication reflects new state; captured older work may finish after DEL. Whole admitted batches remain closed, and unadmitted groups use plain fallback when required capabilities have become ineffective. +- **SC-003**: Every hook category passes exception/malformed-result isolation tests; emitted command sections are at most 512 bytes, every live admitted batch closes once, and rejected operations publish no partial state. +- **SC-004**: An irctest run with the same pinned controller/revision/configuration as baseline has zero new failures or skips; unrelated existing failures are recorded. +- **SC-005**: Delivery stays below the existing one-second maximum and JOIN-storm quiet-channel p99 below 500 ms. Same-machine message throughput regresses by no more than 10% against the recorded main baseline, unless explicitly justified in review; delivery p99, CPU and allocation increases above 10% also require optimization or recorded justification. At 1,000 live sessions, administrative-mutex wait plus immutable snapshot publication/all-session pruning has a 100 ms p99 budget. Measure notification enqueue, configuration I/O, plugin start/stop and socket drain separately; do not wait for previously captured output. #102 supplies the dedicated load measurement and final SC-005 evidence. +- **SC-006**: A test-only new capability composes events, fields and output without adding capability-name decisions to core. Each requested future capability maps to a concrete seam, with additional domain work identified. + +## Assumptions + +- In-process trusted providers and existing discovery/dependency direction remain. Dynamic JAR installation, classloader unloading, untrusted-plugin sandboxing and a general asynchronous event bus are outside scope. +- Core advertises cap-notify in CAP LS, including a runtime with no capability plugins, and does not expose it as a toggleable extension. Implicit support is omitted from LIST; explicit negotiation appears there. Exact-LS and provider-absence tests are updated in #97. +- Snapshot capture is the ordinary-output boundary. Work captured or preparing during a change may finish after DEL; no flush or strict output cutoff is promised. Batch admission alone makes a current prerequisite choice between already prepared framed/plain alternatives, with no plugin callback under core locks. +- Batch's message-tags requirement is behavioral with plain fallback, not a new batch-only negotiation rejection or automatic offer withdrawal. +- Account state/events, persistent history, labeled-response protocol semantics and userhost-in-names are future work; only justified extension seams are included here. +- Public SPI breakage requires a migration guide and PR explanation under the repository's unreleased `0.1.0-SNAPSHOT` rule; first release supplies the version signal. External-plugin compatibility adapters are not required. + +## Delivery + +Four ordered PRs resolve the feature: [#100](https://github.com/jircd/jircd-server/issues/100) introduces the SPI and migrates server-time (these design docs land there); [#101](https://github.com/jircd/jircd-server/issues/101) migrates the other five and removes legacy hooks; [#97](https://github.com/jircd/jircd-server/issues/97) adds the runtime lifecycle and advertises cap-notify; [#102](https://github.com/jircd/jircd-server/issues/102) completes FAILED withdrawal, bounded diagnostics and SC-005 load evidence. Each PR ends with a green `./gradlew build` checkpoint. Intermediate PRs are not claims that all final-feature criteria are already satisfied. diff --git a/specs/014-capability-plugin-spi/tasks.md b/specs/014-capability-plugin-spi/tasks.md new file mode 100644 index 0000000..78fec99 --- /dev/null +++ b/specs/014-capability-plugin-spi/tasks.md @@ -0,0 +1,176 @@ +# Tasks: Capability Plugin Ownership and Runtime Lifecycle + +**Input**: [spec.md](spec.md), [plan.md](plan.md), [research.md](research.md), [data-model.md](data-model.md), [SPI](contracts/capability-spi.md), [lifecycle](contracts/runtime-lifecycle.md), [budgets](contracts/reply-budgets.md), [quickstart.md](quickstart.md). + +**Authorization**: Design docs only until user review. Every implementation task remains unchecked. No commit, push or PR creation is authorized here. + +**Organization**: Exactly four PR task groups in delivery order. Every task has exactly one `[PR100]`, `[PR101]`, `[PR97]` or `[PR102]` owner; `[USn]` identifies the story where relevant. `[P]` permits only disjoint-file preparation after prerequisites, in separate worktrees when writers run in parallel. One writer per worktree. Each PR's final task is its own green `./gradlew build` checkpoint. + +**Tests first**: New behavior/bug tests must demonstrate expected failure before the relevant change. #100/#101 leave existing integration sources unchanged. #97 updates only expected CAP LS/provider-absence behavior for advertised core cap-notify; no unrelated assertions weakened. Basic hook containment lands in #100; complete FAILED withdrawal/diagnostics and dedicated SC-005 measurements land in #102. + +## PR 1 — #100: SPI foundation + server-time + design docs (T001–T016) + +**Goal**: Prove safe typed registration/dispatch, generic reply/frame budgets and one migrated provider, while retaining legacy hooks for the others. Final runtime behavior is delivered by later PRs. + +### Evidence and foundation tests first + +- [x] T001 [PR100] Record baseline server `5912b68` (or exact later main used consistently), machine/JDK/JVM, existing build/coverage/load results and actual report paths in `specs/014-capability-plugin-spi/validation/pr-100/baseline.md`; use coordinator-provisioned project worktrees, never temporary code workspaces or sandbox git writes. +- [x] T002 [P] [PR100] Freeze/hash irctest HEAD `76b91582ca8f8cf6528f750c10f104e0df45865a` plus the already-modified controller/requirements, dependency environment and configuration in `specs/014-capability-plugin-spi/validation/pr-100/irctest-environment.md`; preserve user modifications and obtain coordinator provisioning for isolated harness execution without stash/reset. +- [x] T003 [PR100] Include all ten reviewed feature design files under `specs/014-capability-plugin-spi/` in this PR and record existing integration hashes in `validation/pr-100/integration-fixtures.md`; preserve them through #101 and document the narrow #97 CAP LS expectation exception. +- [x] T004 [P] [PR100] Add descriptor/registrar tests in `jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityRegistrarTest.java` for nonempty protocol-safe ids/offers, uniqueness/reserved core cap-notify, immutable staged registrations, “Immutable set; no self, duplicate, unknown or cyclic hard dependency”, slot/key conflict, bounded metadata and idempotent rollback/close. +- [x] T005 [PR100] Define/document `CapabilityDescriptor.java`, `CapabilityRegistrar.java` and typed contracts under `jircd-core/src/main/java/net/jircd/core/extension/capability/`; add `CapabilityExtension.register` and AbstractCapabilityExtension boilerplate, explicit prerequisite/default/ownership rules and staged resource scopes, retaining contributeTags/includeSenderInFanOut for unmigrated modules only. +- [x] T006 [PR100] Add typed-dispatch tests in `jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityDispatchTest.java` for every event/inbound-tag/outbound-tag/recipient/member/field/group/command registration; assert valid result behavior and exception/null/invalid-result containment/fallback, no partial core outcome damage, and no callback under domain/admin/queue locks; do not demand runtime FAILED withdrawal before #102. +- [x] T007 [PR100] Implement immutable RuntimeSnapshot/typed HookDispatcher in `jircd-core/src/main/java/net/jircd/core/extension/capability/` and wire provider-owned services/commands via `extension/ServerContext.java`, `CommandRegistrar.java`, `ExtensionRegistry.java` and `jircd-server/src/main/java/net/jircd/server/JircdServerApplication.java`; stage atomically, route each provider through exactly one legacy/new path, preserve admin/cloak and dependency direction, and provide basic safe result guard plus existing sanitized operator-visible error reporting for T006; #102 completes provider-level bounded diagnostics and FAILED withdrawal. +- [x] T008 [PR100] Add whole-frame queue tests in `jircd-core/src/test/java/net/jircd/core/session/PreparedDeliveryTest.java` for immutable materialized frames, exact bytes including CRLF/tags, “No partial admission or independently droppable close”, SendQ overflow and release-once cleanup; ordinary output uses captured data without lifecycle revalidation. +- [x] T009 [PR100] Implement generic `PreparedDelivery.java`/`FrameSequence.java` and complete queue append in `jircd-core/src/main/java/net/jircd/core/session/SessionWriter.java`; prevalidate/reserve bounded full sequences, keep callbacks/materialization outside locks/drain, use only the existing queue lock for ordinary append, and retain the legacy batch entry point until #101. +- [x] T010 [PR100] Add `jircd-core/src/test/java/net/jircd/core/config/CapabilityReplyBudgetTest.java` for “Nonnegative finite byte bound evaluated from validated configuration; no null, overflow or unbounded result”, compatible slot max/sum, current NAMES/WHO safe envelope, startup/reload/enable parity and independent escaped tag origins. +- [x] T011 [PR100] Define ReplyLayout/ReplyContribution under `jircd-core/src/main/java/net/jircd/core/extension/capability/` and generic validators in `config/ConfigurationLoader.java`/`HostmaskReplyBudget.java`; reserve discovered compatible decorations, retain safe legacy widths until #101, fit permitted text/list fields only, and account for exact serialized tags/group frames without silently dropping output. + +### server-time test, migration and PR checkpoint + +- [x] T012 [PR100] [US1] Add `jircd-capabilities/server-time/src/test/java/net/jircd/capabilities/servertime/ServerTimeExtensionTest.java` for exact envelope timestamp tag, purity, bounded immutable contribution/cache by envelope/hook identity, failure containment and independence from message-tags. +- [x] T013 [PR100] [US1] Migrate `jircd-capabilities/server-time/src/main/java/net/jircd/capabilities/servertime/ServerTimeExtension.java` to outbound-tag registration; update generic `jircd-core/src/main/java/net/jircd/core/session/CapabilityTagRenderer.java`/`OutboundMessage.java` to share validated immutable contributions with no provider callback on drain, and prove the old hook is not also executed. +- [x] T014 [PR100] [US1] Run provider/core tag-byte/guard tests and unchanged `jircd-integration-tests/src/test/java/net/jircd/integration/Story2NegotiatedDeliveryTest.java` plus the integration suite; record exact outcomes and unchanged-fixture proof in `specs/014-capability-plugin-spi/validation/pr-100/results.md`. +- [x] T015 [PR100] Update public Javadoc in `jircd-core/src/main/java/net/jircd/core/extension/`/`README.md` for the new SPI and temporary bridge; run applicable existing tagged delivery/JOIN loads, record baseline comparison, and complete `.github/REVIEW_CHECKLIST.md`/other-vendor review plus trusted-branch SonarCloud when available in `validation/pr-100/review.md`; fix findings before the final build. +- [x] T016 [PR100] Finish #100 with `./gradlew spotlessApply` then `./gradlew build` green (test/static/85% line/75% branch floors and no aggregate coverage decrease), recording reports in `specs/014-capability-plugin-spi/validation/pr-100/results.md`; checkpoint: safe SPI, server-time wire unchanged, design docs included, legacy hooks still available only for the remaining providers. + +## PR 2 — #101: Five migrations + legacy hook removal (T017–T041) + +**Goal**: Migrate message-tags → echo-message → away-notify → multi-prefix → batch, green after each; no capability-specific behavior remains in core. + +### Shared delivery and message-tags + +- [ ] T017 [PR101] [US1] Add `jircd-core/src/test/java/net/jircd/core/session/MessageDeliveryTest.java` for shared target/send gates, bans/privacy/NOTICE silence, channel sender exclusion/direct self exactly once, merged roles and “unsolicited peer traffic does not inherit request correlation”; assert stable envelope id/time and no registry scan per recipient. +- [ ] T018 [PR101] [US1] Extract `MessageDelivery.java`, immutable DeliveryIntent/RequestContext under `jircd-core/src/main/java/net/jircd/core/session/` from `command/MessageCommandHandler.java`; preserve core authorization/identity, recipient+envelope dedup, typed sender/recipient prerequisites, bounded provider-namespaced context and captured immutable inputs without lifecycle retry. +- [ ] T019 [PR101] [US1] Add `MessageTagsExtensionTest.java`/`TagmsgCommandHandlerTest.java` under `jircd-capabilities/message-tags/src/test/java/net/jircd/capabilities/messagetags/` for client-only normalization/forged keys, sender without negotiation where permitted, exact shared msgid, TAGMSG recipient/send/error behavior and malformed/oversize tag containment. +- [ ] T020 [PR101] [US1] Migrate `jircd-capabilities/message-tags/src/main/java/net/jircd/capabilities/messagetags/MessageTagsExtension.java` to inbound/msgid/recipient/command registrations; move core `session/command/TagmsgCommandHandler.java` into this module using MessageDelivery, remove MESSAGE_TAGS behavior from `CapabilityTagRenderer.java` and hard-wiring from `jircd-server/src/main/java/net/jircd/server/JircdServerApplication.java`. +- [ ] T021 [PR101] [US1] Run module/core tests and unchanged `TagmsgCommandTest.java`, `Story2MessageIdTest.java` and `ConnectionAndCapPrecisionTest.java` in `jircd-integration-tests/src/test/java/net/jircd/integration/`; record green checkpoint/fixture hashes in `specs/014-capability-plugin-spi/validation/pr-101/migrations.md` before echo-message. + +### echo-message + +- [ ] T022 [PR101] [US1] Add `jircd-capabilities/echo-message/src/test/java/net/jircd/capabilities/echomessage/EchoMessageExtensionTest.java` for sender-copy policy on direct/channel/self PRIVMSG/NOTICE/TAGMSG, role merge/no duplicate recipient, immutable sender negotiation and request context propagation. +- [ ] T023 [PR101] [US1] Migrate `jircd-capabilities/echo-message/src/main/java/net/jircd/capabilities/echomessage/EchoMessageExtension.java` to typed sender policy; remove provider scanning/echo assumptions from `jircd-core/src/main/java/net/jircd/core/session/command/MessageCommandHandler.java`; module TAGMSG shares the generic role pipeline. +- [ ] T024 [PR101] [US1] Run provider/core self-direct tests and unchanged `jircd-integration-tests/src/test/java/net/jircd/integration/DirectMessageGuaranteesTest.java`/`Story2NegotiatedDeliveryTest.java`; append green checkpoint to `specs/014-capability-plugin-spi/validation/pr-101/migrations.md` before away-notify. + +### Events and away-notify + +- [ ] T025 [PR101] [US1] Add `jircd-core/src/test/java/net/jircd/core/session/CommittedEventTest.java` and `jircd-capabilities/away-notify/src/test/java/net/jircd/capabilities/awaynotify/AwayNotifyExtensionTest.java` for successful/rejected/no-op away/join/part/quit/nick, accepted repeated AWAY, old NICK identity/pre-departure audience, idempotent cleanup, shared-peer dedup/self exclusion/cloak and committed state after a listener fault. +- [ ] T026 [PR101] [US1] Publish generic committed facts after locks from core `session/ChannelRegistry.java`, `DisconnectCleanup.java`, `command/AwayCommandHandler.java`, `NickCommandHandler.java`, `JoinCommandHandler.java` and `PartCommandHandler.java`, including reused admin SAJOIN/KILL outcomes; move AWAY peer selection/message behavior into `jircd-capabilities/away-notify/src/main/java/net/jircd/capabilities/awaynotify/AwayNotifyExtension.java`, retaining core away state/confirmations. +- [ ] T027 [PR101] [US1] Run event/module tests and unchanged `jircd-integration-tests/src/test/java/net/jircd/integration/AwayNotifyTest.java`/`AwayStatusTest.java`/`DepartureRecipientsTest.java`; append green checkpoint to `specs/014-capability-plugin-spi/validation/pr-101/migrations.md` before multi-prefix. + +### multi-prefix + +- [ ] T028 [PR101] [US1] Add `jircd-core/src/test/java/net/jircd/core/session/MemberReplyRenderingTest.java` and `jircd-capabilities/multi-prefix/src/test/java/net/jircd/capabilities/multiprefix/MultiPrefixExtensionTest.java` for member facts with no multi-prefix flag, consistently ordered held statuses, core highest default, independent identity-slot composition and complete fitted tokens/WHO flags. +- [ ] T029 [PR101] [US1] Migrate `jircd-capabilities/multi-prefix/src/main/java/net/jircd/capabilities/multiprefix/MultiPrefixExtension.java` to status/bound hooks; replace capability-shaped listing/booleans in core Channel/ChannelRegistry/JoinCommandHandler/WhoCommandHandler and admin SajoinCommandHandler with generic snapshots/rendering, and remove hidden fixed capability widths from `jircd-core/src/main/java/net/jircd/core/config/ConfigurationLoader.java`/`HostmaskReplyBudget.java`. +- [ ] T030 [PR101] [US1] Run member/module/budget tests and unchanged `MultiPrefixTest.java`, `NamesByteBudgetTest.java`, `Story7WhoChannelTest.java` and `Story6SamodeTest.java` under `jircd-integration-tests/src/test/java/net/jircd/integration/`; append green checkpoint to `specs/014-capability-plugin-spi/validation/pr-101/migrations.md` before batch. + +### batch + +- [ ] T031 [PR101] [US1] Add `jircd-capabilities/batch/src/test/java/net/jircd/capabilities/batch/BatchExtensionTest.java` plus a registered finite-group socket probe in `jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityGroupTest.java`; assert both/either/missing prerequisite fallback, prefixless framing, valid unique ASCII refs, empty/concurrent/nested bounded groups, full reservation and retained close on admitted groups. +- [ ] T032 [PR101] [US1] Implement group shaping in `jircd-capabilities/batch/src/main/java/net/jircd/capabilities/batch/BatchExtension.java`; move ref/BATCH/tag/gating out of core `session/SessionWriter.java`, migrate enqueueBatch callers/unit fixtures to generic enqueueGroup and validated framed/plain alternatives with whole-queue append; core expresses prerequisites generically, and #97 adds the atomic current-state choice during concurrent transitions. +- [ ] T033 [PR101] [US1] Run batch/provider/generic writer/socket group tests and unchanged integration suite; append green checkpoint to `specs/014-capability-plugin-spi/validation/pr-101/migrations.md`; verify batch-only still negotiates/offers and delivers plain members, with server-time independent of message-tags. + +### Composition, absence and cleanup + +- [ ] T034 [PR101] [US3] Add `jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityCompositionTest.java` for a test-only capability using existing committed events, identity/status slots, bounded RequestContext/roles and finite groups without a core name change; map account-notify/chathistory/label/userhost seams and remaining domain work without implementing those future features. +- [ ] T035 [PR101] [US1] Add `jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityProviderAbsenceTest.java` using isolated catalogs/classpaths with matching desired config; test all six missing providers' offer AND behavior absence, core/unrelated behavior and no destructive JAR deletion. #97 extends this test to assert core cap-notify remains when any/all providers are absent. +- [ ] T036 [PR101] [US1] Remove contributeTags/includeSenderInFanOut, temporary bridge, stale Javadoc and all dead capability behavior from `jircd-core/src/main/java/` and `jircd-server/src/main/java/net/jircd/server/JircdServerApplication.java`; audit every research inventory site and literal/CapabilityName alias, leaving only CAP infrastructure and generic core behavior. +- [ ] T037 [PR101] [US1] Migrate affected core/provider unit API fixtures under `jircd-core/src/test/java/` and `jircd-capabilities/`; document public hook/ServerContext/member/group API migration at definitions and `README.md`, with the unreleased `0.1.0-SNAPSHOT` first-release version signal. Keep existing integration sources unchanged. +- [ ] T038 [PR101] Update ownership/extension claims in `specs/001-ircv3-server/data-model.md`, its `contracts/irc-protocol-commands.md` and `specs/011-away-notify-batch/contracts/away-notify-and-batch.md` to match module-owned behavior and generic reply/group rules; do not claim runtime #97/#102 mechanics are already implemented. +- [ ] T039 [PR101] Run full baseline/candidate irctest with T002's identical frozen harness/config and `--timeout=60 --timeout-method=signal`; record case-level pass/fail/skip comparison and zero new failures/skips under `specs/014-capability-plugin-spi/validation/pr-101/`, retaining unrelated baseline failures and the full unchanged integration fixture proof. +- [ ] T040 [PR101] Run applicable existing tagged fan-out/delivery/JOIN loads, record comparisons and resolve `.github/REVIEW_CHECKLIST.md`/other-vendor/SonarCloud findings as available in `specs/014-capability-plugin-spi/validation/pr-101/review.md`; verify docs/API/zero-branch inventory and all five migration checkpoints before final build. +- [ ] T041 [PR101] Finish #101 with `./gradlew spotlessApply` then `./gradlew build` green, including static/coverage gates and no aggregate coverage decrease; record in `specs/014-capability-plugin-spi/validation/pr-101/results.md`. Checkpoint: all six modules own behavior, legacy hooks gone, unchanged existing integration suite and irctest evidence recorded. + +## PR 3 — #97: Runtime lifecycle + advertised cap-notify (T042–T054) + +**Goal**: Publish snapshot/prune atomically under the existing administrative mutex, then queue notifications. New captures see new state; older work may finish after DEL. Batch prerequisites are chosen at group admission. + +### 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. + +### 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. + +## PR 4 — #102: FAILED withdrawal + bounded diagnostics + load proof (T055–T066) + +**Goal**: Complete runtime hook fault isolation on #97's path and prove SC-005 with dedicated loads and <=10% throughput regression unless justified. + +### Tests first and isolation + +- [ ] T055 [P] [PR102] [US3] Add `jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityFailureIsolationTest.java` for every hook/start/stop/partial-cleanup fault and null/illegal/unauthorized/out-of-bound result; assert FAILED via snapshot/prune/DEL, safe default for failed contribution, committed core outcomes, unaffected peers, bounded sanitized diagnostics, explicit recovery and stop/callback overlap without waiting for active calls. +- [ ] T056 [P] [PR102] [US3] Add `jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityFailureIsolationTest.java`/`CapabilityReplyCompositionTest.java` using real registered probes for every wire-affecting hook; assert exact failure/fallback/other-recipient content, maximum-length composed NAMES/WHO/366, UTF-8/escaped tag origins, full-group SendQ and startup/reload/enable rejection of impossible bounds. +- [ ] T057 [PR102] [US3] Complete runtime fault routing in `jircd-core/src/main/java/net/jircd/core/extension/capability/CapabilityRuntime.java`/HookDispatcher: ordinary exception or invalid-result faults withdraw provider/hard dependents as FAILED through #97's path, use documented fallback only for invalid contribution, continue cleanup after stop failure and emit provider/hook/category diagnostics rate-limited by provider/failure incident without raw payload/exception/credential leakage or automatic recovery. +- [ ] T058 [PR102] [US3] Add `jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityDependencyTest.java` for invalid hard graphs/missing prerequisites unchanged, atomic dependent FAILED withdrawal/notifications, explicit re-enable and late captured callback fault attribution after re-enable; preserve batch's soft message-tags fallback/offer and safe overlapping provider resource access. + +### Dedicated load measurement and PR checkpoint + +- [ ] T059 [P] [PR102] [US3] Add tagged `jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityFanoutLoadTest.java` for 10/100/1,000 mixed recipients and message/AWAY/member/group work; report p99/max/throughput and comparable JFR CPU/allocation with bounded resource cleanup, no uncontrolled rate-limit bursts/global scans and normal-test exclusion. +- [ ] T060 [P] [PR102] [US3] Add tagged `jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityLifecycleLoadTest.java` for 1,000 sessions changed during output/group work; measure <=100 ms p99 administrative-mutex wait + snapshot/all-session prune, with wait/hold, notification enqueue, config I/O, callbacks and drain separate; assert no deadlock, coherent new captures/sets and complete/plain group semantics without a post-DEL cutoff assertion. +- [ ] T061 [PR102] [US3] Run warmup plus at least five identical baseline/candidate runs and `./gradlew loadTest`, record machine/JVM/workload/raw reports/medians/p99/CPU/allocation in `specs/014-capability-plugin-spi/validation/pr-102/performance.md`; require <=10% throughput regression against the pinned main baseline unless justified and optimize or record review justification for >10% other-metric regressions, preserving existing delivery/JOIN budgets. +- [ ] T062 [PR102] Complete canonical FAILED/diagnostic and final lifecycle/performance documentation in `specs/001-ircv3-server/contracts/server-configuration.md`, `contracts/irc-protocol-commands.md`, `data-model.md`, public core Javadoc and `README.md`; retain captured-work/stop-overlap tradeoffs and the future-capability seam map. +- [ ] T063 [PR102] Rerun full baseline/candidate irctest after final lifecycle/fault behavior with T002's frozen harness/config and save case-level comparison under `specs/014-capability-plugin-spi/validation/pr-102/`; require zero new failures/skips, report any observed conformance tradeoff openly and retain known baseline failures rather than hiding them. +- [ ] T064 [PR102] [US3] Run all fault/composition/dependency/socket usage checks and FR-001–016/SC-001–006 traceability against final artifacts, checking existing integration edits remain limited to #97 expectations and no aggregate coverage decrease; record actual results/risks/future-feature exclusions in `specs/014-capability-plugin-spi/validation/pr-102/results.md`. +- [ ] T065 [PR102] Resolve `.github/REVIEW_CHECKLIST.md`/other-vendor/SonarCloud findings as available, validate all four checkpoints and >10% regression decisions in `specs/014-capability-plugin-spi/validation/pr-102/review.md`; fix real findings before the final build and do not create an unrequested extra review round/commit/push/PR. +- [ ] T066 [PR102] Finish #102 with `./gradlew spotlessApply`, all required load/test evidence, then `./gradlew build` green, including static/coverage floors and no decrease; record final reports in `specs/014-capability-plugin-spi/validation/pr-102/results.md`. Checkpoint: FAILED withdrawal/bounded diagnostics and dedicated SC-005 evidence pass; report feature completion without unrequested git writes. + +## Dependencies and Execution Order + +```text +PR #100 T001–T016 → build green + → PR #101 T017–T041: message-tags → echo-message → away-notify → multi-prefix → batch → build green + → PR #97 T042–T054: lifecycle/LS/REQ/groups → build green + → PR #102 T055–T066: FAILED/diagnostics/loads → build green +``` + +Tests precede their implementation within each PR. T004→T005, T006→T007, T008→T009 and T010→T011 stabilize foundation before server-time. In #101 each three-task migration checkpoint gates the next module; T017/T018 supplies shared delivery first. T034/T035 precede final removal/docs/absence verification; T039/T040 precede build. In #97 T042–T045 tests precede T046–T050, then docs/usage/review/build. In #102 T055/T056 precede fault routing; T058 precedes dependency corrections if needed; load classes T059/T060 precede T061 measurement; final validation/review precedes T066. + +Every PR includes necessary actual behavior/API docs and test evidence. No PR is marked complete merely because its code compiles. A failing mandatory step or checkpoint blocks the next PR; no git workaround or implementation is authorized by these design tasks. + +## Parallel Examples + +- **#100 / US1**: T004 descriptor and T006 dispatch test preparation can be read/reviewed independently once contracts are known; T006 needs T005 types to compile. T002 harness evidence is disjoint from descriptor tests. One writer in this worktree. +- **#101 / US1/US3**: Read-only review of a migration checkpoint and later synthetic test design are independent. Do not parallelize the ordered production migrations or edit shared MessageDelivery/TestServer from multiple workers. +- **#97 / US2**: T042 exact-LS, T043 lifecycle socket and T044 grammar/negotiation test preparation touch disjoint files after #101; separate writers require separate worktrees. Group/CAP overlap tests follow stable state contracts, not removed strict-cutoff behavior. +- **#102 / US3**: T055 core and T056 socket tests are disjoint; T059/T060 load-class authoring is disjoint after lifecycle stabilizes. Run measured baseline/candidate samples sequentially to avoid contention. + +## Acceptance Mapping + +| Requirement | Main tasks | +| --- | --- | +| FR-001 | T005–T014, T017–T041 | +| FR-002 | T005–T007, T018–T020, T025–T034 | +| FR-003 | T017–T020, T025–T036, T042 | +| FR-004 | T003/T014, T021/T024/T027/T030/T033, T039/T041/T042, T063/T064 | +| FR-005 | T043/T045/T047, T055–T058 | +| FR-006 | T043/T047/T049/T050/T052, T055–T058 | +| FR-007 | T042–T047/T050/T052, T055–T058 | +| FR-008 | T042/T044/T046/T048 | +| FR-009 | T044/T048/T052 | +| FR-010 | T004/T005, T031–T033, T043/T047/T049/T058 | +| FR-011 | T008/T009, T031–T033, T045/T047/T052, T055/T058/T060 | +| FR-012 | T004–T007, T025, T055–T058/T062 | +| FR-013 | T008–T011, T028–T033, T056 | +| FR-014 | T001/T015/T017/T018/T040/T052, T059–T061 | +| FR-015 | T012–T014, T019–T041 | +| FR-016 | T003/T015/T038/T051/T062 | +| SC-001 | T014/T021/T024/T027/T030/T033/T035–T042 | +| SC-002 | T042–T054, T055–T058 | +| SC-003 | T004/T006/T008/T010/T025/T031/T045/T055/T056 | +| SC-004 | T002/T039/T063 | +| SC-005 | T001/T015/T040/T052/T059–T061/T066 | +| SC-006 | T034/T062/T064 | + +**Task counts**: #100: 16; #101: 25; #97: 13; #102: 12; total **66**. All unchecked, consecutively numbered and owned by exactly one PR. This four-PR sequence replaces the previous story-only delivery grouping; no task carries work into two PRs. diff --git a/specs/014-capability-plugin-spi/validation/pr-100/baseline.md b/specs/014-capability-plugin-spi/validation/pr-100/baseline.md new file mode 100644 index 0000000..3bada25 --- /dev/null +++ b/specs/014-capability-plugin-spi/validation/pr-100/baseline.md @@ -0,0 +1,48 @@ +# Baseline record (T001) + +| Item | Value | +| --- | --- | +| Baseline server revision | `5912b68016133d7cf495bee5efe3f77ca9f09312` ("Support the multi-prefix capability (#99)") | +| Branch base used by this PR | `0d070b11a4b75d038603dc1621773356be21078d` (5912b68 plus the spec-only commit; no code differs) | +| Machine | Apple M4 Pro, 14 cores, 24 GiB, macOS 26.7.1 (Darwin 25.6.0, arm64) | +| JDK / JVM | Temurin 25.0.4+7 LTS, OpenJDK 64-Bit Server VM, mixed mode, sharing | +| Gradle | 9.8.1 (launcher and daemon JVM 25.0.4) | +| Workspace | `.worktrees/014-capability-plugin-spi` (project worktree, no temporary code workspace) | + +## Baseline coverage (SonarCloud, main at `5912b68`) + +Supplied by the coordinator from SonarCloud: **94.7% line, 87.5% branch, 92.6% overall**. + +| | Baseline (SonarCloud) | Candidate (JaCoCo aggregate, `testCodeCoverageReport`) | +| --- | --- | --- | +| Line | 94.7% | 95.08% | +| Branch | 87.5% | 87.67% | + +The two tools measure slightly differently (SonarCloud's overall figure is a blend of line and branch), so this is a like-for-like comparison only in direction: neither line nor branch coverage fell. See [results.md](results.md) for the candidate run. + +## Load evidence (coordinator run of `./gradlew loadTest`) + +All 8 load tests passed on main at `5912b68` and on this branch (measured before the round-2 review fixes; the coordinator re-runs them after). Same machine and JDK as above. Raw numbers: the coordinator's `load-baseline-summary.txt` and `load-repeat.txt`. + +| Load test | main `5912b68` (full run) | branch (full run) | +| --- | --- | --- | +| ExceptionModes | join 0.629 ms, p50 0.243, max 0.401 | join 0.677 ms, p50 0.237 | +| ConcurrentConnectionScale | p50 0.984, p99 1.657 ms | p50 0.867, p99 1.448 ms | +| JoinStorm (2000 clients) | storm 2282 ms; quiet p50 22.3, p99 134.7 ms | storm 2287 ms; quiet p50 31.0, p99 132.1 ms | +| SingleWaveJoinStorm | 1943 ms, 0 overflow disconnects | 1972 ms, 0 overflow disconnects | +| Story1DeliveryLatency (50 messages) | p50 0.770, p99 0.891 ms | p50 0.556, p99 1.918 ms | + +Repeats (3 extra runs each of the two noisiest tests, p99 in ms): + +| Test | main | branch | +| --- | --- | --- | +| Story1 p99 | 1.295, 1.973, 1.292 | 1.858, 1.570, 2.135 | +| Story1 p50 | 0.814, 0.874, 0.806 | 0.614, 0.793, 0.831 | +| ConcurrentConnectionScale p99 | 2.216, 3.122, 1.714 | 1.344, 1.389, 1.416 | + +**Conclusion: no meaningful regression.** Story1's p99 is the maximum of 50 samples, so it is noisy; the branch's 1.57-2.14 ms overlaps main's 0.89-1.97 ms range across runs, and its p50 is equal or lower. Join storms, quiet-channel latency and the overflow count are unchanged. Everything is far under the 1 s budget, and the JOIN-storm budgets (storm under 15 s, quiet p99 under 500 ms) hold with large margin. These runs predate the round-2 fixes. + +## What is not recorded + +- No pre-change local `./gradlew build` of `5912b68` was run by me; the coverage baseline above is the SonarCloud one. +- Candidate build, test and coverage reports live under `build/reports/` (paths in results.md). diff --git a/specs/014-capability-plugin-spi/validation/pr-100/integration-fixtures.md b/specs/014-capability-plugin-spi/validation/pr-100/integration-fixtures.md new file mode 100644 index 0000000..54ceb15 --- /dev/null +++ b/specs/014-capability-plugin-spi/validation/pr-100/integration-fixtures.md @@ -0,0 +1,135 @@ +# Integration fixture hashes (T003) + +Scope check for #100: `jircd-integration-tests/src/test` is **unchanged** — `git diff HEAD -- jircd-integration-tests` is empty at the time of recording, and `git status --short jircd-integration-tests` lists nothing. + +- Files: 117 under `jircd-integration-tests/src/test` +- sha256 of the listing below (`shasum -a 256` over the sorted `shasum -a 256` output): `ba2b56c66a7acd6bcd833be0a4ae0c50e07f837871c593133713932d85b8b474` +- #101 must reproduce this listing exactly. #97 may change only the CAP LS / provider-absence expectations that the advertised core `cap-notify` deliberately affects; record that diff narrowly then. + +## Design files included in this PR (10) + +All ten reviewed design files under `specs/014-capability-plugin-spi/` are already on this branch in commit `0d070b1` (this PR's base): + +`spec.md`, `plan.md`, `research.md`, `data-model.md`, `quickstart.md`, `tasks.md`, `checklists/requirements.md`, `contracts/capability-spi.md`, `contracts/runtime-lifecycle.md`, `contracts/reply-budgets.md`. + +## Hashes (`sha256 path-relative-to-jircd-integration-tests/src/test`) + +``` +8616c67cd07c44c267bc585e030b2fce4a5c578722bbcbe7a554a1354244f7ff ./java/net/jircd/core/session/SessionWriterTestAccess.java +e3e96d043c016a986a9dbff46b194429c341a5685476efc7e59d9b466cbe5b4c ./java/net/jircd/integration/AdminArgon2CredentialTest.java +8f26d87a74836399ae73390adb015244759d3ccf332a2d762ebe00d4bd7463da ./java/net/jircd/integration/AwayNotifyTest.java +f80792970a04b21579c5aab306c7b0f8e892d55a9901fa1b117fa52bd101bf43 ./java/net/jircd/integration/AwayStatusTest.java +17af7df62218ff1d68a27b183af346eca185570f44c07b382b9b4f0ea71e6b29 ./java/net/jircd/integration/BareNamesTest.java +6310712f4d58f0319c68331ff8411074f827236840d6a0726aaa0644d10a179b ./java/net/jircd/integration/CapReqAtomicityTest.java +520eeb81d98e61bbbe0bfb0c1e3d48f61c94189aff1ac3f858cd89661af188ce ./java/net/jircd/integration/ChannelCapacityModesTest.java +222e6027bf52de10d03af1c8907927604a7a1fa3eddd15d0b08c42c5055b2bec ./java/net/jircd/integration/ChannelMembershipGrammarTest.java +351196deef59cb75e195e19c1fecc6b2e0889a99e884efa15021c4d28381c305 ./java/net/jircd/integration/ChannelModeIsTest.java +b47db856aa6979b04c87296bbbb458c3b8432f3a1df06df1e59244d72ff165bc ./java/net/jircd/integration/ConcurrentConnectionScaleLoadTest.java +a82b9de158d35e583648b64b44d241c37b7d3f8167f364ea52103401a160ca6d ./java/net/jircd/integration/ConnectionAdmissionLoggingTest.java +a2f5c176c1024ac1ab2e3340fbd65587ad67848ea9b12a42c5957a2572262f5a ./java/net/jircd/integration/ConnectionAndCapPrecisionTest.java +f6c5340e91ecbfdffae7c12186ef8d118c3495e884a42e89be07e3316c06d2ca ./java/net/jircd/integration/ConnectionLimitsTest.java +3bc50bfaa95347cd025457dcd4604cf678f866fdd4dc91c3579ceef217475fdd ./java/net/jircd/integration/ConnectionMonitorLogTest.java +ead420dbdf908dc2ea92d87ad345e604299be7e4f27235776a72f6655baa724e ./java/net/jircd/integration/ConnectionSmokeTest.java +a966f6bdf54e82bc283028aa7ff70a33cb52650c9767c5f4d4800d84cc7202c2 ./java/net/jircd/integration/ConnectionTokenOpacityTest.java +9c0e9f64effedbe98d65de97c64e05cfbd266947cf8ab58df2750e7045c87dd5 ./java/net/jircd/integration/DepartureRecipientsTest.java +2094ff1da07a4d039f67d49d6f7c1563b20e9f9ba480b5464050767903ab7e4b ./java/net/jircd/integration/DirectMessageGuaranteesTest.java +534993cde34565957ea90156d18ff109c3d1962875c8f813d574fe9b74c81c5a ./java/net/jircd/integration/ExceptionModesLoadTest.java +bb9df2293d707695e6f1c49c61cb811860f2414bb502efb7c32cd7d5612e48e9 ./java/net/jircd/integration/ExceptionModesTest.java +0c8397eaff91dab6f28f6e3b025b2e8cb6dc90ba4b5bc99f6ec1e6311539d4f1 ./java/net/jircd/integration/InformationalCommandsTest.java +30e471de53e99e15ed9de8888da0fc1ce33dbb8a36d11e0a43848b888dc14124 ./java/net/jircd/integration/InformationQueryCompletenessTest.java +548a0543a847dcd77276c90711351ccac79d79d4f996b655e01baa07c7907e04 ./java/net/jircd/integration/InviteNotYetExistingChannelTest.java +818b61217717b6181dd2a48f9b431f8480e5905b8a468cf743d2a844bbd72177 ./java/net/jircd/integration/IrctestConformanceFixesTest.java +6b2bbdd62e23dfa69951b9e5b8042a6be11ad956c2d5c71734ce7797d5d3267b ./java/net/jircd/integration/JoinStormLoadTest.java +4cadfd04a1d2923392e204e01fcf2c28f96cc535b2abaa8ce7ced829ea33e940 ./java/net/jircd/integration/JoinStormSupportTest.java +99076f4d42821efc5ba62b36f42e02645a9c44332c9159c0c815ec26b3e1a4a3 ./java/net/jircd/integration/KeepAliveLoadTest.java +4b2f8da33bb565bf18c8222aa76666eac3ccba44e06bb9791b44901eb0179cab ./java/net/jircd/integration/KillCommandTest.java +6d4e8377d0301c820458faf96e361178958f41e1e7d30a6dcae5a203f7887a5f ./java/net/jircd/integration/LineLengthLimitTest.java +52f7656c640f8f563f1b96a0e2527e71bde5b8370b77e818dc1eae454196b176 ./java/net/jircd/integration/ListenerLifecycleTest.java +36c276322e3d0d35c3355c8681031cd845e375020500a7bd0d023a7c5c53a972 ./java/net/jircd/integration/LogCapture.java +e85603376c116cc601c637ffa69b0d941bfb991f61d6251954d41b29b100c404 ./java/net/jircd/integration/LoopbackBindingTest.java +ee85bdfab5a57283372782d699cc0dcb5fffc560ab2a87656f8e13825e47ca7a ./java/net/jircd/integration/LusersCompletenessTest.java +6394bfeb5afe7d274380590a22b7d578de8b617290c735301a189a35f0309d5b ./java/net/jircd/integration/LusersUserCountsTest.java +46b8edf7dc0feb41b1d10314ad67fe3e71eb4897af8546806d893344a9951a86 ./java/net/jircd/integration/ModeFanoutTest.java +b68b3dad6ea20e389040c4020b607d15100129b755ce6027e19a5757a462620a ./java/net/jircd/integration/MultiPrefixTest.java +0c8f334a81c6b7b38e20581d8f1026e14a161d6a6a4686ba6d27d86d54cdd9ee ./java/net/jircd/integration/NamesByteBudgetTest.java +f3bca9c6551c6fc51117da9b8be9e6456f92fb49241493f1f2a39ac3d2f9a81b ./java/net/jircd/integration/NickBroadcastTest.java +f25bcab26d10bbb6c234b7bf432e309427123f2b065a0065143aca887427bc4d ./java/net/jircd/integration/OperSelfNotificationTest.java +b40d75311c0f9a7810e42cba30446b01cab03792a2ca4f7680741c98af989b5b ./java/net/jircd/integration/OutboundByteBudgetTest.java +9314d0f0792aa5e1d814f9a8129170940a152d06ec77c98926adc45ecc7f4c5e ./java/net/jircd/integration/PingPongTest.java +c46bc908c73353c5e480b030a95ad33784a9a40c20fb99e6b344fa3276288e10 ./java/net/jircd/integration/PingTokenConsistencyTest.java +4e17cf2c9505e6fd56bf1215128748a38d87882f3badbe199c4487bdc33e64d3 ./java/net/jircd/integration/QuitNeighborsTest.java +935aa80dbf0dd33dfdccafd4c4639d4c50d6a95e7ec5840cf6fe889f960ddcd0 ./java/net/jircd/integration/RateLimitConfigTest.java +16e4b9d8b9dca7f55dd9641937d715f4fead68fd6ae45c4ade10814613e4b9c6 ./java/net/jircd/integration/RateLimitLoadTest.java +73cf22fd5779a2fd49bab559b184de040995d43e3aed43bce8efe075d3254747 ./java/net/jircd/integration/RawIrcClient.java +75d32ba097d0732312e3a19934241a316857702b2d12b7e8c173a10d32665db1 ./java/net/jircd/integration/RegistrationTimeoutTest.java +a9b45a17b90387c98554d2b5b67e5f5d4d818c0b079828af87804fb6600aad0a ./java/net/jircd/integration/RepeatedJoinTest.java +5824a1904b6c53ec0cae3b6d6d376fe196d10271127518a34574af28339ae73c ./java/net/jircd/integration/ReplyBudgetReloadTest.java +3e05f90f90428d08f77201d977a62042e86b307f6ba244e5d820a74833ed4352 ./java/net/jircd/integration/SajoinRepeatTest.java +8b1ac5ecebd3f122a26eb198e56d89fc6d72c0091f2baa69921bc3a6858786fb ./java/net/jircd/integration/SendqOverflowTest.java +7f0b09717194a5c3e7b133cfd59eeb2d1d3a603c4463de8022525e9d02f43527 ./java/net/jircd/integration/ServerInfoQueriesTest.java +681112f3d10bf3940a1d9003989d405e8879ab65aeae6b4da69546186300dce0 ./java/net/jircd/integration/SingleWaveJoinStormLoadTest.java +c249fde8ca14ca03c4988985617f6d6ed0416f60388f7835838c82efb6d2799a ./java/net/jircd/integration/Story1ChatTest.java +42837abce8aaef05cc8fc6a478cb4255a09c62b8d2859563ccd03cf07e56b21d ./java/net/jircd/integration/Story1DeliveryLatencyLoadTest.java +ac357d4d4361340667e3f98d3f50e85b51d8b0213a61f9008738684f9f40f724 ./java/net/jircd/integration/Story1DisconnectCleanupTest.java +12414502ded3aaffdb626d036ae9a21c80fa3c9b4837eed68ae51865de0d84de ./java/net/jircd/integration/Story1ListTest.java +e67af08d73375a0851eae1ef859aaf629528dea067fd4e5ddcc3bc708926c975 ./java/net/jircd/integration/Story1NamesTest.java +69aa80ee6949560edb4f211fa30d56b86094d40c200d034535fdd86ab0363621 ./java/net/jircd/integration/Story1NicknameRaceTest.java +a2e4e09a9ccaa418d54ecca02d80ad197248928f1eda31ed5a696ed35cbec919 ./java/net/jircd/integration/Story1QuitBeforeRegistrationTest.java +eb70351fea68a2d6d198c5143f79cd4b780239b319a1ce6002a77586281be10e ./java/net/jircd/integration/Story1RegistrationBurstTest.java +a40026e592372c7f89989b11fe8ca689334e7593dfa02993fb9cb021c0e42dc4 ./java/net/jircd/integration/Story1TopicTest.java +5c0e27405668dddbcc030924410978f8fc8acaf87874eeb77889c1d91b4c5f69 ./java/net/jircd/integration/Story1UserModeTest.java +c6a1117ddf5524e63c493a677f5ab62474831655b6868d3d03afdf20cfd7d21d ./java/net/jircd/integration/Story1UserOneShotTest.java +4d493a5819c3d5f592df433bccace87ab8b75f3faa47ed09f78534f51d033f47 ./java/net/jircd/integration/Story2CapabilityListTest.java +e5c645b707f7506df603d76c6b089deae722564493d23c93177872f4a70b9a6b ./java/net/jircd/integration/Story2MessageIdTest.java +a625ec2145b82e28122c939c4bb864c060e2e5450a27e0a659ea6524156fd8d3 ./java/net/jircd/integration/Story2NegotiatedDeliveryTest.java +c0062d768173697cca33230ed1e693afab7735391f78e36eb93b2b4de1a7a334 ./java/net/jircd/integration/Story4ConfigToggleTest.java +4cfc43c7c672dcce33265f3d7eeb1e58c8bd8c4a719d922b810317e70baa48a2 ./java/net/jircd/integration/Story4InvalidConfigTest.java +a6838e0cf19df7feb179e815df9942ef8d3316899e81138f454672078dee8b25 ./java/net/jircd/integration/Story4LengthLimitConfigTest.java +ca4585fe1d2df7a70adab15496e6cc7e63439f7e86b582c13de55f4309d3bc25 ./java/net/jircd/integration/Story5AlwaysAvailableTest.java +cd4a3ab12cc9680422495fb008e0f914c0d2878fb48cd6f76747bafa9dc1aa25 ./java/net/jircd/integration/Story5BanCapTest.java +5c0bf020d83f14ab4192e231810f26d346b5425735851bdfd58465b27b0dfe95 ./java/net/jircd/integration/Story5BanJoinTest.java +4c6101fb22b639f060ae80b4597f9307cbd2a4f9894d510982adb0c394056514 ./java/net/jircd/integration/Story5BanListTest.java +e141f5578d0f63de97943cd02b96d677e30f4bc970d5f4fa2cdbfd670e9925d1 ./java/net/jircd/integration/Story5BanMuteTest.java +244de48a6287fcf713e092f86c757188faa3bbdb52dc4c4058fef75728442d93 ./java/net/jircd/integration/Story5BanRealHostnameTest.java +753bcaf44ba1470eef4b3dafb81b2eea82a980c5686b534ee1a6ed45dbf77272 ./java/net/jircd/integration/Story5InviteTest.java +ad5ff65d667f44cffe9b462f13d65399448e6e8fe3228343742c53a753d97426 ./java/net/jircd/integration/Story5KickPermissionTest.java +14113f4d87a2abbc718b8d195c9d687e2927f16ed7ebb4ae039eaddcf03923b5 ./java/net/jircd/integration/Story5KickTest.java +bdf56d347148206fe4054f2ff0cb659ccc86b01b52affa4c566f065445655d82 ./java/net/jircd/integration/Story5ModeCapConfigTest.java +84f21311da19287d3e74bb80f8f22faf470449fea5410d0cbc80efec9e5f2b37 ./java/net/jircd/integration/Story5ModeGroupingTest.java +55380a6374cb3aaa1f60074bed8712bb8eb47333cd64797cc0c2d45c2cd1c957 ./java/net/jircd/integration/Story5ModeRestrictionTest.java +14690e226b40c1895146e88b0cf5b35431549f343ea221884bbe07ae39a7c562 ./java/net/jircd/integration/Story5NamesPrefixTest.java +c449c75ac934dde3b9695c3c228cc38e419a6db9dcf12d8534bc020f2cd709c3 ./java/net/jircd/integration/Story5OperatorGrantTest.java +759d360d52f3da74471fb4561f00efda67cdcce20826391d37c0ea0f35ba5dcd ./java/net/jircd/integration/Story5PrivacyTest.java +a2a071f6bf7513806a2007b1d4584bfcde2ef8e6e9f81b0ebad53e1cef846f43 ./java/net/jircd/integration/Story5VoiceTest.java +3a22ae8b488b8f126e75b045282d64b4108a4efe813c1ee6c90de3881d354b5e ./java/net/jircd/integration/Story6ExtensionToggleTest.java +9cb3e576ef284848c53a42f76ca99511dab99ca73b924c510cac8128dcced198 ./java/net/jircd/integration/Story6NonToggleableRejectionTest.java +8731cd9c3dcdaf069d752a143c066838a865ec6c6645f94ef16b62b94e986054 ./java/net/jircd/integration/Story6OperTest.java +ca1aa187dec4d590be6e74a6b0d78160646e0fbe3a7d024cb3508c4b0e30a45a ./java/net/jircd/integration/Story6OperUserModeTest.java +35e97d2c5e0858bb56bd79ed6df229a6081c058806ab7198fa58148113d814bb ./java/net/jircd/integration/Story6PrivilegeGateTest.java +3e523d8f67c77c726cbe59e6472ee421999d13afb8581e01a09aef59bab142ce ./java/net/jircd/integration/Story6RehashTest.java +aae856fb4897f72075e023e522ba5a33195b6a885fdabfea1594c6348f22ab89 ./java/net/jircd/integration/Story6SajoinTest.java +df1465cb58eac9270908488d33129405bbe354575f840e6f2e9b9d80a6a9da8a ./java/net/jircd/integration/Story6SamodeTest.java +098e71f1eaf9658a495767bbc37bdb36e453dbdce9128952e46801e17d0564cf ./java/net/jircd/integration/Story6SelfLockoutTest.java +0f6854ef8ed6150a52f952a70dbb987fa1ae7b51d4f5fc75d5067d4488124235 ./java/net/jircd/integration/Story6WhohostTest.java +f7f4a42d48fd52b9ea9e10683b74cc13ea23e97d24a0308925af6af70bcc4e25 ./java/net/jircd/integration/Story7AdminLookupTest.java +c09dbca373cb2f08bdb586bd1271be93409d591aa498776a509819385874887e ./java/net/jircd/integration/Story7NoSuchNickTest.java +3b2fb281b25c2c53a4c5ccdbb473af50a17fcd22dec09ff516ed5f491f2f8612 ./java/net/jircd/integration/Story7RegularLookupTest.java +938ef18659d32b2ce884cfda52b888b2a633b07719618c23fe0c8449aa11c0c9 ./java/net/jircd/integration/Story7SelfLookupTest.java +95b8a62019a0922258706d8a8ef61371258db7f978c3b71c051f4dcd850317f5 ./java/net/jircd/integration/Story7WhoChannelTest.java +007de646e2248ec6951678badd5900969807b8a01bcaa1ac6d98aa8fa470cbbc ./java/net/jircd/integration/Story7WhoInvisibleTest.java +ef7480795bedc4137b0813f121f98dc70168bfa05f4c1f16970e4ba265727df5 ./java/net/jircd/integration/Story7WhoMaskConfigTest.java +b7dc0096f2160bbcd6a4a4b8822c92eecd065f74034f502cc3c1461b0cd07a46 ./java/net/jircd/integration/TagmsgCommandTest.java +c627b229b085fbfe14592ae261695a0a03aef3e60e4bf97d1ed935522eff969d ./java/net/jircd/integration/TestServer.java +03f07f4507839c653b8c90a5c5b61ac7e9d87799bad4b53e3d9e18491af6f749 ./java/net/jircd/integration/TlsCertificateConfigTest.java +4aac311e3b29419d566f143b99939b5b35a3c1d0b6c2e1f1c8f57d256986f43b ./java/net/jircd/integration/TopicLockTest.java +5bf5a3dcbefaeee541898fee204ff3348da166a2221121112f3ebef2ff768b24 ./java/net/jircd/integration/TopicMetadataTest.java +33223582e837b8856854f740299707825810964dd2d293e215dfdd55c620ce4d ./java/net/jircd/integration/Utf8ValidationTest.java +b64c73d155416fd20e9f505e91600a9d2dd88d44b58bde1026fa98bda87454e2 ./java/net/jircd/integration/WallopsCommandTest.java +80bf79830526051b3c569d6c907ea6a8f6425c1c062b34c14030d16cc17b5eac ./java/net/jircd/integration/WhowasCommandTest.java +2478cb9b4b543ee75b62f06d56ba4b1dab5c61f89727c3f3bd62314d27d5e861 ./java/net/jircd/integration/WhowasCountTest.java +9679f67d499bb1a2fc60608063f70adf35e0217bcf3d2a9b84f37a565d2fc069 ./java/net/jircd/server/JircdServerTestAccess.java +f969e2178d546a41456501d52997b5e37955483176f3995aea1769018e6ac864 ./resources/tls/test-fullchain.pem +18abaece94ef9e1f09d4175ea341fbc9944e7962d740fb4fc3ab20eafe09d7e5 ./resources/tls/test-keystore.p12 +0bb079e001856cababb75b1a38315bc0482e644113730f26d6c0db924ab72597 ./resources/tls/test-privkey-encrypted.pem +ef2f5b4ec6093524999cf4c708ba2da79d869a1ffd09900070abe8f531a41519 ./resources/tls/test-privkey.pem +``` diff --git a/specs/014-capability-plugin-spi/validation/pr-100/irctest-environment.md b/specs/014-capability-plugin-spi/validation/pr-100/irctest-environment.md new file mode 100644 index 0000000..57ed391 --- /dev/null +++ b/specs/014-capability-plugin-spi/validation/pr-100/irctest-environment.md @@ -0,0 +1,23 @@ +# irctest environment freeze (T002) + +Recorded read-only on 2026-10-22; nothing in the harness was modified, stashed or reset. irctest was **not run** for #100 (the coordinator asked for no irctest runs here; the full comparison is #101's T039). + +| Item | Value | +| --- | --- | +| Harness path | `/Users/gcastro/Projects/jircd/irctest` | +| HEAD | `76b91582ca8f8cf6528f750c10f104e0df45865a` | +| Working-tree modifications (preserved) | `irctest/controllers/jircd.py` (+85/-2), `requirements.txt` (+1) | +| Untracked | `logs/` (run output, not part of the freeze) | +| sha256 `irctest/controllers/jircd.py` | `a9c9ba8d43fcaa35e4b138799d921f6c202c7f8f5d879c22650c6e86ff664f94` | +| sha256 `requirements.txt` | `ac78ec82fad14e79d42d0062ee16d677601318d8c502fd2427aae61d3cea9d3d` | +| sha256 `pyproject.toml` | `c08f9d76d0941cabae084d7802e09fb154629cfa86a33409988a3efb1863fea2` | +| sha256 of `git diff` (both modified files together) | `d78cc79c32b2be06d57fbfbc4cb2ec3f9a1df6f414b8190b02abca589979aa9b` | +| Python venv | `.venv`, Python 3.14.9 (venv config built from 3.14.7), `pyvenv.cfg` sha256 `97d27a2826a65e64fe1f46e8fec4c4af3b49e3979ca02955a0e39d3602811dae` | +| Installed packages | bcrypt 5.0.0, ecdsa 0.19.2, filelock 3.32.3, iniconfig 2.3.0, packaging 26.3, pip 26.2.1, pluggy 1.6.0, Pygments 2.21.0, pytest 9.1.1, pytest-timeout 2.4.0, six 1.17.0, websockets 17.0.1 | +| Server launcher | `JIRCD_LAUNCHER=/jircd-server/build/install/jircd-server/bin/jircd-server`, built with `./gradlew :jircd-server:installDist` | +| JDK | Temurin 25.0.4+7 (`JIRCD_JAVA_HOME` must point at the same JDK for baseline and candidate) | +| Run flags (T039) | `--controller irctest.controllers.jircd --timeout=60 --timeout-method=signal --junitxml=` | + +## Open item for the coordinator + +Isolated harness execution for #101 needs a coordinator-provisioned harness worktree carrying the reviewed controller patch above; I did not create one (git writes are out of scope). The recorded hashes let #101 prove it ran the identical frozen state. diff --git a/specs/014-capability-plugin-spi/validation/pr-100/results.md b/specs/014-capability-plugin-spi/validation/pr-100/results.md new file mode 100644 index 0000000..357112c --- /dev/null +++ b/specs/014-capability-plugin-spi/validation/pr-100/results.md @@ -0,0 +1,63 @@ +# PR 1 (#100) results (T014, T016) + +Refreshed after review round 2. Recorded on the uncommitted working tree of branch `014-capability-plugin-spi` (base `0d070b1`), JDK Temurin 25.0.4+7, Gradle 9.8.1, Apple M4 Pro. + +## Final build (T016) + +| Command | Outcome | +| --- | --- | +| `./gradlew spotlessApply` | applied, then clean | +| `./gradlew build` (after the PR #104 Sourcery fixes; not `--rerun-tasks`, so some modules were up to date) | **BUILD SUCCESSFUL**: 1148 tests in the module XML reports, 0 failed, 0 skipped; Spotless, SpotBugs (main and test), PMD (main and test) and `testCodeCoverageVerification` all green | + +An earlier plain `./gradlew build` on the same tree failed `spotlessJavaCheck` and `pmdTest` (two test files emptied by my own scripting error, then four PMD test-source notes); both were fixed and the build re-run. A later plain build also passed before one last change (a stop failure after withdrawal is now recorded as FAILED, with a test), after which the `--rerun-tasks` run above is the final one. + +### Coverage (aggregate over every module with `src/main/java`) + +Report: `build/reports/jacoco/testCodeCoverageReport/` (`html/`, `testCodeCoverageReport.xml`). + +| | Covered | Floor | Result | +| --- | --- | --- | --- | +| Line | 94.95% (5475 of 5766) | 85% | pass | +| Branch | 87.30% (2207 of 2528) | 75% | pass | +| New package `net.jircd.core.extension.capability` | lines 569 of 579, branches 252 of 292 | n/a | | + +Against the SonarCloud baseline of main at `5912b68` (94.7% line, 87.5% branch), line coverage is higher and branch coverage is 0.2 points lower; the two tools count differently (JaCoCo here, SonarCloud there), so this is a direction check, not an exact comparison. The coordinator's SonarCloud run of the branch is the like-for-like figure. + +### Test reports + +Per-module HTML: `/build/reports/tests/test/index.html`; XML: `/build/test-results/test/`. PMD: `/build/reports/pmd/`. SpotBugs: `/build/reports/spotbugs/`. + +## Tests added or changed (T004–T013) + +Written before the production code in each case, but I did not run them against a missing implementation to record a red result: they were compiled and run together with the code. Two test files were also rewritten after a scripting mistake truncated them (content unchanged). + +| Class (module) | Tests | Covers | +| --- | --- | --- | +| `CapabilityRegistrarTest` (core) | 15 | T004: ids/offers, reserved `cap-notify`, uniqueness, immutable staging, dependency validation, tag/slot/command conflicts, bounds, idempotent close | +| `CapabilityDispatchTest` (core) | 28 | T006: every registration kind with valid, throwing, null and invalid results; defaults; gating; no hook under the dispatcher/snapshot monitor | +| `PreparedDeliveryTest` (core) | 10 | T008: exact bytes with tags and CRLF, immutability, whole-sequence admission, overflow without a prefix, release once, no admission after close | +| `CapabilityReplyBudgetTest` (core) | 8 | T010: NAMES/WHO formulas, slot max vs sum, invalid bounds, startup/reload parity | +| `ExtensionRegistryCapabilityTest` (core) | 25 | T007: atomic publish, conflicts, cleanup after every failure kind, dependents, commands, enable-time budget, stop failure | +| `CapabilityTagRendererMigrationTest` (core) | 6 | T013: one path per provider, one evaluation per envelope, no drain-time callback, withdrawal | +| `ServerTimeExtensionTest` (server-time) | 7 | T012: exact tag, purity, bound at the extremes, immutability, containment, independence from message-tags | +| `ConnectionHandlerCommandTableTest` (core) | 1 | removal by handler identity over the real command table | +| `CapabilityNegotiatorTest` (core, changed) | 7 | one fixture changed, see deviations | + +`ServerTimeExtensionPurityTest` was deleted: it called `contributeTags`, which server-time no longer implements; `ServerTimeExtensionTest` replaces it. + +## T014: unchanged integration suite + +`jircd-integration-tests` ran inside the build above: all its tests passed, including `Story2NegotiatedDeliveryTest` (1 test) and the rest of the suite. Fixture proof: `git diff HEAD -- jircd-integration-tests` is empty and `git status --short jircd-integration-tests` lists nothing; hashes are in [integration-fixtures.md](integration-fixtures.md). Wire behaviour for server-time is therefore unchanged by the existing socket tests (the `time` tag still arrives as before, now through the typed hook). + +## Load comparison (T015) + +Run by the coordinator on main and on this branch; all 8 load tests passed on both and show no meaningful regression. Numbers and the conclusion are in [baseline.md](baseline.md). They predate the round-2 review fixes and are being re-run. irctest was not run for #100. + +## Deviations from the spec or tasks + +1. **Duplicate offers are rejected at enable** (spec: "duplicate offers cannot silently replace another provider"). The existing unit test `twoExtensionsOfferingTheSameNameDoNotBreakRequests` enabled two providers with one offer name; it is now `aSecondExtensionOfferingTheSameNameIsRejectedAndRequestsStillWork`. No integration test depends on it. +2. **`disable` refuses while another published provider requires it** (`IllegalStateException`, nothing changes) instead of cascading; the transitive withdrawal is #97's scope and no provider declares dependencies yet. +3. **Hooks other than outbound tags have no live caller** in #100. They are registered, validated and dispatched with containment, and tested through `HookDispatcher`, but core call sites arrive with their migrations in #101. +4. Provider commands are installed on enable (never replacing a command already handled) and unregistered on disable. +5. **`PreparedDelivery` carries the captured `RuntimeSnapshot` view** as the data model describes, but nothing reads it yet. +6. Review rounds 1 and 2 (Codex and SonarCloud, run by the coordinator) changed several designs after the first version of this record: command removal is by handler identity (`CommandRegistrar.unregister(command, handler)`, no defaults), frame and group validation are stricter, and typed and legacy tags share one enqueue-time budget. See [review.md](review.md). diff --git a/specs/014-capability-plugin-spi/validation/pr-100/review.md b/specs/014-capability-plugin-spi/validation/pr-100/review.md new file mode 100644 index 0000000..424efa9 --- /dev/null +++ b/specs/014-capability-plugin-spi/validation/pr-100/review.md @@ -0,0 +1,98 @@ +# Review record (T015, partial) + +**Status:** the implementer's self-check (below), a SonarCloud branch analysis and a Codex review are done. SonarCloud (coordinator run): quality gate green with **0 open issues** after the 20 earlier issues were fixed. Codex findings 1-8 are fixed (table below); finding 9 (baseline/candidate load evidence for T001/T015) is the coordinator's and no `loadTest` was run here. + +## Codex review findings and resolution + +| # | Finding | Resolution (test first) | +| --- | --- | --- | +| 1 | P1: a provider command could replace a core command; no rollback | `CommandRegistrar` gained `isRegistered`/`unregister` (backed by `ConnectionHandler.hasHandler/removeHandler`). Enable rejects an already-owned command; installation rolls back on any failure, including Errors, and withdraws the publication; disable unregisters exactly what the provider installed (the 421 adapter is gone). Tests: `aProviderCannotReplaceACommandCoreOrAnotherExtensionAlreadyOwns`, `aFailureInstallingLaterCommandsRollsBackTheEarlierOnesAndThePublication`, `providerCommandsAreInstalledOnEnableAndRemovedOnDisable` | +| 2 | P1: `FrameSequence` did not validate IRC syntax or the per-origin tag budget | new `FrameSyntax` (command token, prefix, parameters free of CR/LF/NUL, middle-parameter rules, tag keys, 4094-byte budget for server and client origins separately) runs before materialization. Tests: `rejectsFramesWhoseSyntaxWouldSplitOrCorruptTheWireLine`, `enforcesTheServerOriginTagBudgetNotJustTheCombinedSection` | +| 3 | P2: individually valid tag hooks could overflow together at drain and drop the core message | `CapabilityTagRenderer` composes the hook origin at enqueue; a hook that would overflow it is dropped and reported, the core message goes out. Test: `hooksThatAreIndividuallyValidButOverflowTogetherLoseTheLaterOneNotTheMessage` | +| 4 | P2: a throwing `start()` leaked what it had acquired | `stop()` is now always called after a failed start (documented on `Extension.start`). Test reversed: `aStartFailureStillCleansUpWhateverWasAcquiredBeforeItThrew` | +| 5 | P2: group plans could lose members or leave an opener unmatched | plans must contain every member once and in order (extra tags allowed), openers only before the first and an equal number of closers after the last, within the byte bound; otherwise plain members. Test: `groupPlansMustCoverEveryMemberInOrderWithMatchedFraming` (six malformed plans). Limit: openers and closers are matched by count and position, not by batch reference, which is provider-specific | +| 6 | P2: dispatch used reference limits | `RuntimeSnapshot` carries the configured `ReplyLimits` (refreshed on attach and on reload via `ExtensionRegistry`); slot bounds are evaluated against them at dispatch. Test: `replyBoundsAreEvaluatedAgainstTheConfiguredLimitsNotTheReferenceOnes` | +| 7 | P2: `CapabilityServices` exposed the mutable runtime | it now wraps a `Supplier` and `ExtensionRegistry` exposes `capabilitySnapshot()` instead of `capabilityRuntime()`. Test: `capabilityServicesExposeOnlyAReadOnlySnapshot` | +| 8 | P2: discovery silently dropped duplicate ids | `register` rejects a different instance with an existing id; `discover` logs and skips it and continues. Test (real `ServiceLoader` path via a services file): `duplicateProviderIdsAreRejectedAtRegistrationAndDiscoveryReportsThem` | +| 9 | P2: load/coverage evidence | recorded in [baseline.md](baseline.md) (coordinator's load runs) | + +## Codex round 2 and SonarCloud round 2 + +| # | Finding | Resolution (test first) | +| --- | --- | --- | +| 1 | typed and legacy tags were budgeted separately, so a typed contribution plus message-tags `msgid` could drop the message at drain | legacy tags are now evaluated once per envelope (`OutboundMessage.legacyTags`) and counted first; a typed contribution that would overflow the combined origin is dropped and reported. Test: `aTypedContributionThatOverflowsWithALegacyOneIsDroppedNotTheMessage` (writer level) | +| 2 | unregister removed whatever handler held the command | `unregister(command, installedHandler)` removes only that instance (`ConcurrentHashMap.remove(key, value)`); the registry tracks what it installed. Tests: `ConnectionHandlerCommandTableTest` (real command table, OPER replacement sequence), `disablingAProviderLeavesAReplacementInstalledByAnotherExtensionAlone` | +| 3 | `CommandRegistrar` defaulted `isRegistered`/`unregister` | both are abstract, so a registrar that cannot remove handlers cannot exist; the lambda form no longer compiles. Test: `aRegistrarMustSupportOwnershipAwareRemovalSoNoProviderHandlerOutlivesWithdrawal` | +| 4 | a failing unregister skipped withdrawal or the rest of disable | rollback attempts every removal and always withdraws; disable attempts removals, `stop()`, state and extension-point release, keeps the first failure and suppresses the rest. Tests: `anUnregisterFailureDuringRollbackStillWithdrawsThePublication`, `anUnregisterFailureDuringDisableStillStopsAndFinalizesTheProvider` | +| 5 | group plans accepted members posing as framing and unpaired openers | openers must pair with closers from the inside out (same command, `+ref` against `-ref`). Tests: three more counterexamples in `groupPlansMustCoverEveryMemberInOrderWithMatchedFraming` (`[m2,m1,m2,m1]`, `[BATCH +r,m1,m2,BATCH +s]`, wrong reference) | +| 6 | tag values with NUL passed | `FrameSyntax` and the typed outbound-tag validator reject NUL in tag values. CR and LF are not rejected: the serializer escapes them, they are legal in a tag value, and a client-only `+` tag can carry them. Tests: `PreparedDeliveryTest` (NUL value), `aServerTagValueCarryingNulIsAFault` | +| nit | duplicate-discovery log not asserted | a logback `ListAppender` asserts the warning names the duplicate class and id | +| Sonar | S135, S3776, two S1612, S1130, S5853 | restructured the three slot loops and split the group validator; method reference; removed `throws`; chained assertions | + +## Codex round 3 + +| # | Finding | Resolution (test first) | +| --- | --- | --- | +| 1 | `addSuppressed` on the same instance throws `IllegalArgumentException`, skipping finalization when unregister and `stop()` throw one shared exception | failures are combined by `accumulate`, which never suppresses an exception into itself. Test: `oneSharedExceptionFromUnregisterAndStopStillFinalizesTheProvider` | +| 2 | removal caught only `RuntimeException`, so a `LinkageError` from `unregister` left the state ENABLED, skipped `stop()` and kept bookkeeping | removal, rollback and disable catch `RuntimeException \| LinkageError` and continue every cleanup step. Test: `aLinkageErrorFromUnregisterStillStopsAndFinalizesTheProvider` | + +## Self-check against REVIEW_CHECKLIST.md + +| Area | Result | +| --- | --- | +| Sonar S1181 | `RuntimeException \| LinkageError` is caught at the extension boundary (the spec requires LinkageError); no `Throwable`/`Error` catch, and `VirtualMachineError` propagates (`linkageErrorsAreContainedButVirtualMachineErrorsPropagate`) | +| S7467 / S1186 / S2445 / S3077 | unused catch parameters are `_`; no empty methods besides commented ones; only `synchronized` methods or private final monitors; the one `volatile` (`CapabilityRuntime.snapshot`) holds an immutable class | +| S107 (more than seven parameters) | the 11-argument `HookRegistration` constructor and the 9-argument registrar staging method became a package-private builder | +| S2925 / S5778 / S5853 (tests) | no `Thread.sleep` (the writer test waits on a semaphore); the registrar test builds its arguments outside the `assertThatThrownBy` lambdas. Some other lambdas still make two calls (for example `Set.of(...)` inside a lambda); I did not exhaustively rewrite them, so expect possible S5778 notes from Sonar | +| Failure and cleanup paths | a failed `register`, budget check, publication conflict or command registration closes the registrar, stops the started provider and leaves nothing published (`ExtensionRegistryCapabilityTest`); a stop failure after withdrawal keeps the provider withdrawn and FAILED and keeps its extension-point claim until a retried `disable` succeeds | +| Concurrency and reload | the snapshot is immutable and replaced atomically (publication is serialized by the runtime; `enable`/`disable` by a private lifecycle lock that no message path takes); the renderer captures one snapshot per preparation; a captured `PreparedTags` renders after disable. Hooks run with no dispatcher lock (`noHookRunsWhileTheCallerHoldsTheDispatcherOrSnapshotMonitor`). The one monitor a hook can run under is the per-envelope `OutboundMessage` memo monitor, which existed before this change | +| Protocol size limits | tag contributions are measured with the serializer's escaping against a declared bound of at most 4094 bytes; frame sequences are serialized and counted exactly before admission; a sequence that does not fit overflows the session whole (`PreparedDeliveryTest`); NAMES/WHO bounds go through one arithmetic (`CapabilityReplyBudgetTest`) | +| Hostile or oversized input | registrations are bounded (64 hooks, 16 dependencies, 8 prerequisites, 16 owned keys, 8 request attributes of 256 bytes, 8 event intents, 8 framing overhead frames); a provider's output is rejected on its declared bound before it is copied into a recipient plan | +| Extension boundaries | an exception, null, forged key, widened audience, unordered/foreign status symbol, unsafe identity token and oversized plan are each dropped whole with a default and a report; unrelated providers and committed state are unaffected (`CapabilityDispatchTest`) | +| Validation edge cases | empty/null/duplicate/oversized ids, names, keys, bounds and dependencies are rejected at staging or snapshot build; `cap-notify` and the `batch` tag are reserved | +| Rejected requests change nothing | a rejected publication leaves the previous snapshot; a refused disable (a published dependent) changes nothing | +| Security | the report carries provider id, hook kind and category only; no exception text, tags or client content is logged by the dispatcher | +| Determinism | no locale-dependent formatting added; the server-time formatter is UTC with a fixed pattern | +| Public API compatibility | `ServerContext` gained a `capabilityServices` component, so every construction site changed (main application and one test fixture); `ConfigurationLoader` and `HostmaskReplyBudget` keep their old signatures. The unit fixture `CapabilityNegotiatorTest.twoExtensionsOfferingTheSameNameDoNotBreakRequests` had to change because duplicate offers are now rejected at enable (see results.md). Migration notes are in the README and the Javadoc; `0.1.0-SNAPSHOT` is unreleased | +| Tests that can pass while the code is wrong | assertions check exact content (tag maps, status strings, byte counts, ordering of written lines), counters prove the legacy method is never called and the hook runs once per envelope, and a migrated provider's disable is asserted both for new captures and for already-captured work | +| Docs and contracts | README section, `package-info`, `CapabilityExtension`/`AbstractCapabilityExtension` Javadoc. They say what is delivered (typed tags, safe dispatch, budgets, whole-frame queue) and what is not (runtime FAILED withdrawal, event publication, the other five migrations) | + +## Known limits, for the reviewer + +- Event, inbound-tag, recipient, member-status, identity, reply-field and group hooks are registered, validated and dispatched with containment, but no core call site invokes them yet: that wiring belongs to the migration PRs that give them a real caller. Only the outbound-tag path is live (server-time). +- A faulty hook is contained and reported on each call; it is not withdrawn (that is FAILED withdrawal in #102). The report is throttled to one warning per provider/hook/category per 10 s. +- Provider commands are installed on enable and removed on disable; an in-flight call already holding the handler may still finish. + +## Sourcery (PR #104) + +15 threads, all verified against the code. Tests were written before the fixes; I did not run them against the old code to watch each fail (no git stash/worktree allowed), so "test added" means a regression test exists, not that I saw it red. + +| # | Real? | Test | Fix | +| --- | --- | --- | --- | +| S1 | yes | `aLeftoverHandlerRefusesToRunAndIsRemovedByRetryingTheDisable`, `aFailedRollbackKeepsTheLeftoverForRetryAndItsHandlerRefusesToRun` | installed-command records survive a failed removal (only the failed ones); each batch has a `live` switch the guarded handler checks, so a leftover answers 421 instead of running (it runs only while the published snapshot contains its provider); `disable` on a FAILED provider with unfinished cleanup retries removal and `stop`; `enable` first clears leftovers | +| S2 | yes | `hooksAreNeverVisibleBeforeTheCommandsAreInstalledNorAfterAFailedEnable` | commands are installed first, hooks published last in one atomic step; rollback withdraws before removing | +| S3 | yes (the registry monitor was held across `start`/`register`/`stop`) | `providerCallbacksRunOutsideTheRegistryMonitor` | `enable`/`disable` are no longer `synchronized`; a private `ReentrantLock` serializes lifecycle changes and nothing on a message path or registry read takes it | +| S4 | yes (during `disable`, state stayed ENABLED after withdrawal) | `legacySelectionFollowsTheCapturedSnapshotNotTheLiveEnabledState` | legacy providers are picked from the captured snapshot's provider ids (every enabled capability publishes at least an empty descriptor) | +| S5 | yes for event-intent lists, recipient-role sets and tag maps (the records already copy) | `aTagResultThatChangesAfterTheFirstReadIsValidatedAndUsedAsOneCopy`, `aRecipientRoleSetThatCannotBeReadTwiceStillYieldsItsValidatedCopy`, `anEventIntentListThatCannotBeReadTwiceStillYieldsItsValidatedCopy` | `validated` copies first (inside the hook-fault containment), validates the copy and returns only it | +| S6 | yes | `aBoundThatExceedsOrThrowsAtTheConfiguredLimitsIsAValidationErrorNotAnIllegalArgument` | `validateReplyBudgets` turns the bound `IllegalArgumentException` into a `ConfigurationException`, for startup and REHASH | +| S7 | yes | `ConfigurationReloaderLinkageTest` | `reconcileSection` catches `RuntimeException \| LinkageError`; the other extension is enabled and the new configuration published | +| S8 | yes | `aLegacyProviderThatThrowsOrReturnsNullContributesNothingAndTheMessageStillGoesOut`, `aLegacyContributionThatThrowsIsNullOrOversizedIsDroppedAndReported` | `HookDispatcher.legacyOutboundTags` contains throw/null/oversized like a hook and reports it | +| S9 | yes | `tagDataBytesAtMostAgreesWithTheExactCountAndRejectsOversizeFromRawLength` | `MessageSerializer.tagDataBytesAtMost` rejects on the raw character count (a lower bound of the escaped size) before building the section; used by the dispatcher, `FrameSyntax` and the renderer | +| S10 | yes | `anyErrorFromStartOrStagingStillStopsTheProviderAndRecordsItFailed` | `enable` cleans up in a `finally` guarded by an `enabled` flag (no broad `catch Error`) | +| S11 | yes | `oversizedLegacyTagsAreOmittedAtEnqueueNotAtDrainSoTheMessageIsNotDropped`, updated `CapabilityTagRendererBudgetTest` | legacy contributions are budgeted with the typed ones at enqueue; an overflowing one is dropped and reported, and drain renders the already-validated maps (no second provider call). Two existing unit tests asserted the old drop-the-message behaviour and were rewritten | +| S12 | yes | `aRejectedRequiresProvidersCallStagesNoneOfItsIds` | the whole list is validated against a copy and added only if every id passes | +| S13 | yes | `aFailedStopKeepsTheExtensionPointClaimUntilARetriedDisableSucceeds` | the claim is released only after a clean cleanup; documented on `disable` (retry the `disable` to release) | +| S14 | yes (only for handlers with a value-like `equals`) | `unregisterComparesTheInstalledHandlerByIdentityNotEquality` | removal uses `computeIfPresent` with `==` | +| S15 | yes | `tagKeysMustMatchTheIrcv3KeyGrammar` | `FrameSyntax` accepts only `[+][vendor/]name` with name `[A-Za-z0-9-]+` and a hostname-shaped vendor | + +Notes: where a legacy and a typed provider return the same key, the typed value now wins at drain (the order is legacy then typed, the same order the budget is checked in); no shipped provider does this. Legacy providers are visited in the snapshot's provider-id order, not registry order. + +## Codex (Sourcery fixes) + +| # | Real? | Test | Fix | +| --- | --- | --- | --- | +| C1 | yes: REHASH skipped `disable` for a FAILED provider | `rehashRetriesTheCleanupOfAFailedProviderWhoseDesiredStateIsDisabled` | `reconcileSection` calls the idempotent `disable` whenever the desired state is disabled | +| C2 | yes | `aFailedReEnableAfterAFailedStopFinishesThePendingCleanupAndReleasesTheClaim` (the exact sequence) | `enable` finishes a pending cleanup (`cleanUp`, shared with `disable`) before starting, and a failed enable releases the claim whenever its own cleanup is complete | +| C3 | yes | no deterministic test of the instant between `publish` and the flip (there is no seam inside `publish`); covered by the leftover-handler tests, which now depend on the snapshot | a provider command runs only while the current snapshot contains its provider, so hooks and commands flip in the same atomic step; the separate `live` flag is gone | +| C4 | yes | `aProviderCallbackCannotReEnterTheLifecycle`, `aProviderCallbackThatCallsReloadFailsFastInsteadOfDeadlocking` | `enable`, `disable` and `reload()` throw `IllegalStateException` on the thread holding the lifecycle lock (checked before the reload monitor is taken); documented on `Extension.start/stop` and `CapabilityExtension.register` | +| Sonar | yes | n/a | removed two unused `throws Exception`, a text block for the YAML, and the conditional moved inside `Map.of` |