Skip to content

Add IRCv3 labeled-response and batch capabilities - #110

Open
MrIron-no wants to merge 7 commits into
UndernetIRC:mainfrom
MrIron-no:feat/labeled-response
Open

Add IRCv3 labeled-response and batch capabilities#110
MrIron-no wants to merge 7 commits into
UndernetIRC:mainfrom
MrIron-no:feat/labeled-response

Conversation

@MrIron-no

Copy link
Copy Markdown
Contributor

Implements the IRCv3 batch and labeled-response capabilities (labeled-response depends on batch; CAP REQ for one without the other is NAKed).

How it works

Local commands. A labeled command's output to the requesting connection is captured while its handler runs (label_capture_intercept() from send_buffer()), then released as a bare ACK, a single line carrying @label=, or a BATCH +ref labeled-responseBATCH -ref with @batch=ref body lines, depending on how many lines the handler produced. The label is removed from the line's ambient tag list before the handler runs, so nothing a handler broadcasts (WALLOPS, channel traffic, S2S) can pick it up; only the requester's own response is labeled.

LIST. Unconditionally multi-line and paginated across event-loop ticks, so it streams instead of buffering: m_list() commits to a BATCH immediately and each list_next_channels() tick's output goes out tagged @batch=ref through the real sendq path. A superseding LIST closes the old batch cleanly (RPL_LISTEND folded in) and opens a fresh one.

Remote queries over S2S. For hunt_server_cmd()-routed commands (WHOIS nick nick, remote STATS, …) the origin server forwards the command with @label= and drops its own capture. Only the server that actually answers captures its own output, keyed on the remote requester, and emits S2S-addressed BA <numnick> +ref / -ref / AK <numnick> lines. Intermediate hops relay them (ms_batch(), ms_ack(), and do_numeric() for the tagged body) and never reassemble anything. Gated on NETWORK_FEATURES like the other federated tags; on a mixed network the reply degrades to plain unlabeled output, as the spec allows.

New files: ircd/label.c / include/label.h (the capture machinery, split out of send.c), ircd/m_batch.c, ircd/m_ack.c. New features CAP_BATCH, CAP_LABELED_RESPONSE.

Fixes made during review of this branch

  • Parked LIST superseded by a new LIST closed the new batch after its first tick. parse.c inferred "the handler started a new listing" from a cli_listing() pointer comparison, and the freed-then-reallocated ListingArgs routinely comes back at the same address. Now matched by ref.
  • Remote requester captures were keyed on the S2S link, so anything the answering server sent down that link during the dispatch (an RPING it sent onward, remote CONNECT's WALLOPS) was swept into the response. Captures now belong to the requesting client itself and the intercept runs on the intended recipient before link resolution.
  • A single-line non-numeric reply lost its label at the relaying hop: parse_server mistook the answering server's labeled NOTICE for a new labeled request. Only user-prefixed labeled lines are treated as requests now.
  • S2S @time= carve-out moved from a 22-way strcmp chain in msg_tag.c onto the command table as MFLG_NO_S2S_TIME.

Tests

tests/labeled_response/ (30 tests, single- and multi-server): ACK / single-line / BATCH shapes, CAP dependency, label value limits and escaping, LIST at scale and genuinely parked mid-pagination, remote WHOIS/STATS at one and two hops, interim traffic not mislabeled, the regressions above, and no label leakage from a labeled WALLOPS. tests/pr_network_features_compat/test_nf_compat_labeled_response.py covers the mixed-version network. The S2S time tests in tests/pr_msgtags_compat/ pin the MFLG_NO_S2S_TIME set.

Notes

  • Getting a LIST to actually park in the docker harness needs the client connected to the container IP with a throttled receive buffer; the TinySendQ port alone never pauses because send_buffer() flushes every 1KB. The new list-pause test does this.
  • That test also exposed a pre-existing upstream bug, not fixed here: list_next_channels() re-sends the hash bucket it paused on (LIST: list_next_channels() re-sends the hash bucket it paused on (duplicate RPL_LIST entries) #109).
  • Known accepted gap: with NETWORK_FEATURES off, a hunted command answers with an immediate ACK and the real reply follows unlabeled.

Implements labeled-response (dependent on batch) via a per-connection
capture in parse.c/send.c: a labeled command's output is deferred and
released as a bare ACK, a single labeled line, or a labeled BATCH
depending on how many lines it produced.

LIST is a special case: rather than deferring output for an end-of-
command decision, a labeled LIST commits to BATCH immediately
(label_capture_stream_active(), send.c) and streams each line to the
wire tagged batch=ref as list_next_channels() (hash.c) produces it, one
event-loop tick at a time. Streamed output goes through the real
send_buffer()/cli_sendQ() path, so a labeled LIST now genuinely pauses
and resumes across ticks exactly like an unlabeled one, and can never
overflow an in-memory buffer. A second LIST/STOP superseding a still-
parked one closes the old batch cleanly (RPL_LISTEND folded in, then
BATCH -ref via reopen()+finish(), not abort()) and opens a fresh BATCH
for the new command -- fixes a parse.c bug where any already-parked LIST
continuation caused an unrelated labeled command's capture to be
misrouted into it.

Propagates labels across S2S: hunt_server_cmd()-routed commands (the
"WHOIS nick nick" trick, remote STATS) carry @Label= over the wire
(sendcmdto_one_hunted(), gated on FEAT_NETWORK_FEATURES like other
federated tags) to whichever server actually answers on the original
requester's behalf (parse_server()'s labeled-response wrapper), with
BATCH/ACK relayed back hop by hop by address (ms_batch()/ms_ack(),
m_batch.c) the same way do_numeric() already relays numerics. Confirmed
safe across a partially-upgraded network (nf_compat topology): a peer
with NETWORK_FEATURES off never sees an @Label=/@Batch= tag, and the
worst case (a hop in the middle doesn't propagate) degrades to a plain,
unlabeled reply -- the spec's own sanctioned fallback for a response a
server can't honestly label, not a hang or a crash.

Adds test coverage in tests/labeled_response/: core capture/ACK/BATCH
shapes, CAP dependency enforcement, LIST at scale and under interruption
(channels seeded via a fake P10 server-link burst rather than real
client JOINs, which trip ircu's unrelated target-change flood limit at
scale), the async-parking regression above, remote WHOIS/STATS over a
real S2S round trip at one and two hops, and (tests/pr_network_features_
compat/) the same over a mixed-version network.

Claude-Session: https://claude.ai/code/session_01SyPrvPCSmSY7hxx3QL3cQn
- parse_server(): re-verify `from` is still alive after the handler
  runs via a safe (non-dereferencing) hash lookup instead of trusting
  rc == CPTR_KILLED, which only fires when cptr == victim. A
  server-origin QUIT frees `from` (the user) while cptr (the link)
  survives, so the old check missed it and label_capture_finish()
  dereferenced freed memory. Servers verify via FindNServer()/cli_yxx()
  (numeric), matching the lookup style already used earlier in the
  same function, rather than by name.
- exit_one_client() (s_misc.c): properly finish outstanding label
  captures for a remote client before it's freed, instead of leaving
  parse_server() to discover the free too late.
- sendcmdto_one_hunted(): use label_capture_abort() on handoff so any
  already-buffered lines are flushed unlabeled instead of discarded.
- m_list.c / send.c: harden label_capture_reopen() to report whether
  it actually found the capture, closing the window where a stale
  label_ref could misdirect a superseding LIST's RPL_LISTEND into an
  unrelated capture.
- test_remote_labeled_quit_for_own_user_does_not_crash_hub: add
  cleanup and a bounded retry on the trailing WHOIS check. Diagnosed
  a flaky timeout in the full labeled_response suite (never in
  isolation); confirmed via an ASan rebuild (0 violations, 26/26
  pass) that this is session-level connection-accounting noise
  (IPcheck.c, shared across tests on 127.0.0.1), not a real defect.
do_numeric()'s pattern of rewriting the sender to &me at every relay
hop is fine when every server agrees on FEAT_HIS_REWRITE, but on a
mixed-config network an earlier hop's rewrite permanently overwrites
sptr before a later hop -- one that might have HIS configured
differently -- ever gets a say, silently discarding the true origin.
Relaying hops now forward sptr untouched; only the hop that actually
MyConnect()s the target makes the rewrite call, in both ms_batch()
and ms_ack().

Also gate local delivery on the target actually having CAP_BATCH and
CAP_LABELED_RESPONSE active (matching the exact check parse.c already
uses before starting a capture), so a client that dropped or never
negotiated these caps doesn't get a raw BATCH/ACK line sprung on it
by a remote peer's relay.
parse.c decided "this handler just started a new listing" by comparing
the cli_listing() pointer before and after the handler ran. m_list.c's
superseding path frees the old ListingArgs and immediately allocates a
new one of the same size, which the allocator routinely returns at the
same address, so a labeled LIST that replaced a parked one looked
"unchanged" and parse.c finished its brand-new streaming capture on the
spot: BATCH -ref went out after the first tick and every later RPL_LIST
plus the final RPL_LISTEND left unlabeled, outside any batch.

m_list() already stamps the capture ref into ListingArgs.label_ref (via
label_capture_stream_active()) when it starts a paginated listing, so
parse.c now simply checks whether the current listing carries this
dispatch's ref. The pointer snapshot and the redundant re-stamp are gone.

Regression test: two labeled LISTs where the second (LIST >0, so it
starts a real listing rather than acting as LIST STOP) arrives while the
first is genuinely parked. Getting a LIST to park needs the hub's kernel
write to block: the test connects to the container IP (docker-proxy
buffers on its own), throttles the client's receive side, and stops
reading. The pre-existing test on the TinySendQ port never parked
anything because send_buffer() flushes every 1KB.

The test asserts on distinct channel names: list_next_channels() breaks
before args->bucket++ and so re-sends the bucket it paused on -- a
pre-existing upstream bug (UndernetIRC#109), not addressed here.

Also: correct a stale "500-line/64KB" comment (the limits are 5000 lines
and 1MB), a misleading comment in hash.c about when label_ref is set,
list CAP_BATCH / CAP_LABELED_RESPONSE in the example config, and fix the
copyright line in m_batch.c.
send.c had grown by 550 lines of capture machinery that only touched it
at two points. It now lives in ircd/label.c with its interface in
include/label.h:

- send_buffer() calls label_capture_intercept(), which returns whether
  it took the line into the active capture.
- sendcmdto_one_hunted() asks label_capture_active_for() for the
  requester's active capture instead of reading the window statics.

struct MsgTagCtx and msgtagctx_init() were private to send.c; label.c
snapshots the context by value for every captured line, so they move
into send.h. The intercept takes the effective tag context (cache ctx or
explicit ctx) rather than the cache, so struct TagSendCache stays
private.

ms_ack() moves from m_batch.c into its own m_ack.c, mirroring the
one-handler-per-file layout, with the same CAP_BATCH + CAP_LABELED_
RESPONSE delivery gate as ms_batch() and parse.c's capture start.

No behaviour change. Comment pointers that said "in send.c" now say
label.c.
On the server answering a hunted command for a *remote* requester, the
capture window was keyed on cli_from(requester) -- the S2S link -- and
send_buffer() compared the resolved destination. Every line the handler
sent down that link during the dispatch was swept into the requester's
response, whoever it was addressed to. RPING is the cleanest trigger:
"RPING <target> <start>" is hunted to <start>, which then sends an RPING
to <target>; with the requester's own server as <target> that RPING went
back down the requesting link and was captured as if it were the reply.
Remote CONNECT's WALLOPS broadcast had the same problem.

- The capture list moves from struct Connection to struct Client, and
  label_capture_start() keys the window on the requesting client itself.
  A remote requester's captures are no longer shared with, or disposed of
  alongside, other remote users behind the same link.
- send_buffer() runs the intercept on the *intended recipient* before
  resolving it to the link. sendcmdto_one() / sendcmdto_prio_one() pass
  the recipient through and only use the resolved link to choose the wire
  form; sendcmdto_one_hunted() looks up the capture by requester.
- parse_server() only treats a *user*-prefixed labeled line as a request
  to answer. hunt_server_cmd() always forwards with the requester as
  prefix, so a labeled line with a *server* prefix can only be an
  answering server's reply being relayed back. Previously the relaying
  hop mistook such a single-line non-numeric reply (e.g. ms_connect()'s
  "Host not listed in ircd.conf" NOTICE) for a new labeled request,
  wrapped it, and stripped the label before delivering it. Numerics were
  never affected because do_numeric() relays them first. The now
  unreachable server-prefix verification branch is removed.

Regression tests (multi-server): remote RPING back down the requesting
link resolves as a bare ACK followed by a plain RPONG; a remote CONNECT
NOTICE reply reaches the requester with its label. Also a test that a
labeled WALLOPS labels only the sender's own echo -- other recipients,
local and on a linked server, see no label.
msg_tag_s2s_needs_time() decided which commands must not get @time=
invented on the S2S wire with a chain of 22 ircd_strcmp() calls against
token strings, evaluated from msgtagctx_init() on every send. The list
was the only place that knowledge lived, far from the command table.

The policy is now a flag on the msgtab[] entries themselves:
MFLG_NO_S2S_TIME on the link/state and net-admin protocol commands
(BURST, EB, EA, SERVER, PING, PONG, SETTIME, ASLL, RPING, RPONG, UPING,
PASS, ERROR, PROTO, SQUIT, CONFIG, JUPE, GLINE, SLINE, DESTRUCT) and on
the server<->services RPC (XQUERY, XREPLY) that services parse
positionally without stripping tags. msg_tag_s2s_needs_time() looks the
token up through the existing token trie (msg_find_by_tok(), parse.c)
and reads the flag -- a few character steps instead of 22 compares.

Same set of commands, same behaviour: tokens not in the table at all
still get @time=, as before.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant