Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ behaviour, so removing a module from the classpath removes its offer and its beh
| `onEvent(kind, hook)` | Reacts to a committed `AWAY_CHANGED`, `JOINED`, `PARTED`, `KICKED`, `QUIT` or `NICK_CHANGED` with delivery intents for peers drawn from the event's audience; recipients are narrowed to those that negotiated the capability | `away-notify` |
| `onMemberStatus`, `onNamesIdentity`, `onReplyField` | Bounded decorations of schema-declared reply slots (the status of a NAMES token or channel WHO entry, the NAMES identity) | `multi-prefix` (status) |
| `onOutputGroup(prerequisites, hook)` | Framing of a finite group; the group is validated and queued whole, or sent plain when the owner or a prerequisite was not negotiated | `batch` |
| `command` | Provider-owned command; it can never replace a command core or another extension handles, and is removed (answered as unknown) when the provider is withdrawn | `message-tags` (`TAGMSG`) |
| `command` | Provider-owned command, part of the published snapshot rather than core's command table, so it appears, disappears or moves to another provider exactly when the snapshot does; it can never replace a command core or a server extension handles, and is answered as unknown while no published provider owns it | `message-tags` (`TAGMSG`) |

Hook results are untrusted. An exception, a `null` or an out-of-bounds result is dropped whole,
reported by provider id, hook and category only, and replaced by the documented default; committed
Expand All @@ -352,9 +352,18 @@ channel monitor held; a kind nobody listens for costs nothing, and a faulty list
the transition. Client messages go through `MessageDelivery`, which owns target resolution, the
send gates, the envelope (one id and send time for every recipient) and role selection.

**Status.** Runtime FAILED withdrawal of a faulty provider, atomic runtime enable/disable with
`CAP NEW`/`DEL`, and the dedicated load measurements are later changes in the same feature
(`specs/014-capability-plugin-spi/`); this change does not provide them.
**Runtime enable and disable.** `EXTENSION`, `REHASH` and `SIGHUP` share one lifecycle. Under the
capability runtime's administrative mutex, a disable publishes the new snapshot and removes the
withdrawn name, together with any hard dependents', from every connected session in one step; then
`CAP DEL` goes to every client that sent `CAP LS 302` or negotiated `cap-notify`. An enable sends
`CAP NEW` but restores no one's negotiation. Core advertises `cap-notify` in `CAP LS` even with no
provider installed. Work prepared before a change may still be delivered after the `DEL`; anything
prepared after it sees the new state. A batch group picks framed or plain at admission, against the
current state, so a queued group always keeps its close. Provider `start`/`stop` run outside the
mutex and must tolerate overlapping callbacks from captured work.

**Status.** Runtime FAILED withdrawal of a provider whose hook faults, and the dedicated load
measurements, are a later change in the same feature (`specs/014-capability-plugin-spi/`).

**Migrating code that used the earlier API.** `0.1.0-SNAPSHOT` is unreleased, so these changes
carry no version bump until the first release.
Expand All @@ -370,6 +379,11 @@ carry no version bump until the first release.
| `MessageCommandHandler.resolveRecipients`, `deliveryTargets`, `includeSenderInFanOut` | `MessageDelivery.authorize` and `deliver` |
| `TagmsgCommandHandler` in core | Owned by the `message-tags` module |
| `ReplyBudgetCatalog.legacyCompatible()` | `ReplyBudgetCatalog.empty()` plus the providers' own declared bounds |
| Mutating `ClientSession.negotiatedCapabilities()` | It is now an immutable snapshot; replace the whole set with `replaceNegotiatedCapabilities` |
| `CapabilityNegotiator.isAcceptable`, `apply` | `CapabilityNegotiator.candidate` (validates without changing anything) and `request` (validates and applies under the administrative mutex) |
| `CapabilityNegotiationGrammar.ls`, `list` | `lsReplies`, `listReplies` (with 302 continuation lines); `notifications` for `CAP NEW`/`DEL` |
| A capability provider's command looked up in core's command table (`CommandRegistrar`) | `ExtensionRegistry.capabilityCommand(Command)`; dispatch consults it after core's table. `HookDispatcher.guardedCommand(registration, live, refused)` is gone; the one-argument form wraps faults |
| `CapabilityRuntime.withdraw` returning a snapshot; `ExtensionRegistry.disable` refusing while a dependent is enabled | `withdraw` returns the withdrawn provider ids; disabling a prerequisite withdraws its hard dependents in the same step |

Reply decorations are budgeted by generic layouts (`ReplyLayout`) rather than a per-capability
width: startup, `REHASH` and runtime enable reserve every installed provider's declared slot bytes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,10 @@ private ClientSession user(String nick, String... negotiated) {
}

private void tagmsg(ClientSession sender, String line) throws Exception {
commands.get(Command.TAGMSG).handle(sender, MessageParser.parse(line));
extensions
.capabilityCommand(Command.TAGMSG)
.orElseThrow()
.handle(sender, MessageParser.parse(line));
}

private String received(ClientSession session) {
Expand All @@ -114,10 +117,12 @@ private String received(ClientSession session) {
}

@Test
void theProviderInstallsTagmsgAndWithdrawalRemovesIt() {
assertThat(commands).containsKey(Command.TAGMSG);
void theProviderOwnsTagmsgWhileEnabledAndWithdrawalRemovesIt() {
// A provider's command is part of the published snapshot, not core's command table.
assertThat(extensions.capabilityCommand(Command.TAGMSG)).isPresent();
assertThat(commands).isEmpty();
extensions.disable("message-tags");
assertThat(commands).doesNotContainKey(Command.TAGMSG);
assertThat(extensions.capabilityCommand(Command.TAGMSG)).isEmpty();
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,28 @@
*/
package net.jircd.core.capability;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import net.jircd.core.extension.ExtensionRegistry;
import net.jircd.core.session.ClientSession;
import net.jircd.protocol.CapabilityName;
import net.jircd.protocol.CapabilityNegotiationGrammar;

/**
* The {@code CAP LS}/{@code REQ}/{@code ACK}/{@code NAK}/{@code END} state machine
* (FR-006/FR-007/FR-008). Gates registration completion while a negotiation is in progress. Always
* available — never one of the toggleable extensions itself (FR-035); the offered/accepted list is
* sourced live from {@link ExtensionRegistry}, never cached (FR-007).
* available — never one of the toggleable extensions itself (FR-035). The offered list is core
* {@code cap-notify} plus the published providers', read live from {@link ExtensionRegistry}, never
* cached (FR-007).
*
* <p>Every read-validate-commit of a session's negotiation runs under the capability runtime's
* administrative mutex ({@link #runUnderControl}), the same one a disable holds while it prunes, so
* a request racing a disable either lands before the prune and is pruned, or after it and is
* refused; it can never bring a withdrawn name back.
*/
public final class CapabilityNegotiator {

Expand All @@ -40,44 +50,71 @@ public void beginNegotiation(ClientSession session) {
session.setNegotiating(true);
}

/** Core cap-notify and every published provider's capability, sorted. */
public List<String> currentlyOfferedCapabilityNames() {
return extensionRegistry.offeredCapabilities().stream().map(Capability::name).toList();
List<String> offered = new ArrayList<>(extensionRegistry.capabilitySnapshot().offeredNames());
offered.add(CapabilityName.CAP_NOTIFY);
offered.sort(Comparator.naturalOrder());
return offered;
}

/** Runs {@code action} under the administrative mutex CAP state shares with publication. */
public void runUnderControl(Runnable action) {
extensionRegistry.capabilityControl().runUnderControl(action);
}

/**
* Whether every requested name (a {@code -name} removal is checked by its bare name) is currently
* offered. Changes nothing, so the caller can size the reply before committing.
* The explicit set {@code requested} would leave {@code session} with, applying its tokens in
* order, or empty if the whole request must be refused. Changes nothing. A positive token must be
* offered now. A {@code -name} token is accepted if the name is held at that point (offered or
* not), or if it is a known capability that is simply not held (for example one a disable already
* pruned); an unknown, unheld name refuses the request. Notification support is not removable by
* a client that indicated {@code CAP LS 302}. An empty request is refused.
*/
public boolean isAcceptable(List<String> requested) {
public Optional<Set<String>> candidate(ClientSession session, List<String> requested) {
if (requested.isEmpty()) return Optional.empty();
Set<String> offered = new HashSet<>(currentlyOfferedCapabilityNames());
return requested.stream().allMatch(name -> offered.contains(bareName(name)));
Set<String> candidate = new HashSet<>(session.negotiatedCapabilities());
for (String token : requested) {
if (!isRemoval(token)) {
if (!offered.contains(token)) return Optional.empty();
candidate.add(token);
continue;
}
String name = bareName(token);
if (name.equals(CapabilityName.CAP_NOTIFY)
&& session.highestCapVersion() >= CapabilityNegotiationGrammar.NOTIFY_VERSION) {
return Optional.empty();
}
if (!candidate.remove(name) && !isKnown(name)) return Optional.empty();
}
return Optional.of(candidate);
}

/**
* Applies a {@code CAP REQ} atomically: the session changes only if {@link #isAcceptable}, and
* then through one swap of the whole negotiated set. Returns whether the request was accepted;
* the {@code ACK}/{@code NAK} echo is the caller's, built from the client's original list so it
* keeps order and duplicates (005-fix-batch-conformance FR-009).
* Whether {@code name} is core cap-notify or offered by any discovered provider, enabled or not.
*/
public boolean request(ClientSession session, List<String> requested) {
if (!isAcceptable(requested)) {
return false;
}
apply(session, requested);
return true;
private boolean isKnown(String name) {
return name.equals(CapabilityName.CAP_NOTIFY)
|| extensionRegistry.knownCapabilityNames().contains(name);
}

/** Applies already-validated additions and removals as a single set replacement. */
public void apply(ClientSession session, List<String> requested) {
Set<String> updated = new HashSet<>(session.negotiatedCapabilities());
for (String name : requested) {
if (isRemoval(name)) {
updated.remove(bareName(name));
} else {
updated.add(name);
}
}
session.replaceNegotiatedCapabilities(updated);
/**
* Applies a {@code CAP REQ} atomically under the administrative mutex: the session changes only
* if the whole request is acceptable, and then through one swap of the whole negotiated set.
* Returns whether the request was accepted; the {@code ACK}/{@code NAK} echo is the caller's,
* built from the client's original list so it keeps order and duplicates
* (005-fix-batch-conformance FR-009).
*/
public boolean request(ClientSession session, List<String> requested) {
return extensionRegistry
.capabilityControl()
.underControl(
() -> {
Optional<Set<String>> accepted = candidate(session, requested);
accepted.ifPresent(session::replaceNegotiatedCapabilities);
return accepted.isPresent();
});
}

private static boolean isRemoval(String name) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,8 @@
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import net.jircd.core.extension.Extension;
import java.util.Map;
import net.jircd.core.extension.ExtensionRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* The core, manually-triggered reload operation (research.md "Configuration reload mechanism") —
Expand All @@ -32,7 +30,15 @@
*/
public final class ConfigurationReloader {

private static final Logger LOG = LoggerFactory.getLogger(ConfigurationReloader.class);
/**
* A reload that validated and was applied, with each extension whose reconciliation failed and
* why. A provider whose start fails ends FAILED without undoing the rest of the reload.
*/
public record ReloadOutcome(ServerConfiguration configuration, Map<String, String> failures) {
public ReloadOutcome {
failures = Map.copyOf(failures);
}
}

private final Path configPath;
private final ConfigurationLoader loader;
Expand Down Expand Up @@ -72,51 +78,52 @@ public Path configPath() {
* thread holds
*/
public ServerConfiguration reload() throws ConfigurationException, IOException {
return reloadWithOutcome().configuration();
}

/**
* As {@link #reload()}, also reporting each extension whose reconciliation failed.
*
* @throws IllegalStateException if called from inside a provider lifecycle callback
*/
public ReloadOutcome reloadWithOutcome() throws ConfigurationException, IOException {
// Checked before the reload monitor is taken, so a callback never queues behind a reload that
// is itself waiting for the lifecycle lock the callback holds.
extensionRegistry.requireNoLifecycleCallback();
return reloadSerialized();
}

private synchronized ServerConfiguration reloadSerialized()
throws ConfigurationException, IOException {
/**
* The whole desired configuration is validated before anything changes: the file, the reply
* budgets, and the candidate provider graph (hard dependencies present and acyclic, offers and
* owned slots unique). A validation failure changes no configuration, negotiation or
* notification. A valid reload is then one lifecycle operation that no {@code EXTENSION} toggle
* interleaves with: providers to enable start first, prerequisites first, then a single
* publication swaps the capability snapshot together with the new reply limits, the advertised
* lengths and {@link #current()}, and only then are withdrawn providers stopped. Enable never
* restores an earlier negotiation.
*/
private synchronized ReloadOutcome reloadSerialized() throws ConfigurationException, IOException {
ServerConfiguration reloaded;
try (InputStream in = new FileInputStream(configPath.toFile())) {
reloaded = loader.load(in);
}
loader.checkReplyBudgets(
runningServerName, reloaded.nicknameMaxLength(), reloaded.channelNameMaxLength());
reconcile(reloaded);
this.current = reloaded;
extensionRegistry.updateConfiguredLengths(
reloaded.nicknameMaxLength(),
reloaded.channelNameMaxLength(),
reloaded.topicMaxLength(),
reloaded.maxModesPerCommand());
return reloaded;
}

private void reconcile(ServerConfiguration configuration) {
reconcileSection(configuration.capabilityStates());
reconcileSection(configuration.serverExtensionStates());
}

private void reconcileSection(java.util.Map<String, Boolean> desiredStates) {
for (var entry : desiredStates.entrySet()) {
String id = entry.getKey();
boolean desiredEnabled = entry.getValue();
Extension.State state = extensionRegistry.stateOf(id);
boolean currentlyEnabled = state == Extension.State.ENABLED;
try {
if (desiredEnabled && !currentlyEnabled) {
extensionRegistry.enable(id);
} else if (!desiredEnabled) {
// Idempotent; for a FAILED provider it retries the cleanup that is still pending.
extensionRegistry.disable(id);
}
} catch (RuntimeException | LinkageError e) {
LOG.warn("Failed to reconcile extension '{}' to desired state {}", id, desiredEnabled, e);
}
ExtensionRegistry.DesiredState desired =
new ExtensionRegistry.DesiredState(
reloaded.capabilityStates(),
reloaded.serverExtensionStates(),
reloaded.nicknameMaxLength(),
reloaded.channelNameMaxLength(),
reloaded.topicMaxLength(),
reloaded.maxModesPerCommand());
Map<String, String> failures;
try {
failures = extensionRegistry.applyDesiredState(desired, () -> this.current = reloaded);
} catch (ExtensionRegistry.InvalidDesiredStateException e) {
throw new ConfigurationException(e.getMessage());
}
return new ReloadOutcome(reloaded, failures);
}
}
Loading
Loading