Skip to content

Dev - #111

Merged
h3xxit merged 12 commits into
mainfrom
dev
Sep 16, 2026
Merged

Dev#111
h3xxit merged 12 commits into
mainfrom
dev

Conversation

@h3xxit

@h3xxit h3xxit commented Sep 16, 2026

Copy link
Copy Markdown
Member

Summary by cubic

Adds per-client ownership for stateful communication protocols. Previously all protocols used process-wide instances and clients had no close(); factory-registered protocols now get isolated instances and teardown, while shared protocols keep their existing behavior and remain process-owned.

New Features

  • Adds register_communication_protocol_factory, with factory instances taking precedence and late registrations adopted on first use.
  • Adds protocol cleanup with UtcpProtocolCloseError; every close attempt is awaited and failures are raised together.
  • Failed create() calls close the protocols this client made and deregister only the manuals this attempt registered, even when cancelled.
  • Ensures batch registrations finish before reporting failures.
  • Moves utcp-mcp and utcp-websocket to per-client factories; HTTP-based protocols remain shared.
  • Bumps utcp, utcp-mcp, and utcp-websocket to 1.2.0.

Refactors

  • Fixes required-documentation extraction for prose, section headers, inline code, undocumented classes, and cross-platform links.
  • Updates generated documentation and CLI security text to describe environment-based argument handling.

Written for commit e120001. Summary will update on new commits.

Review in cubic

h3xxit and others added 9 commits September 15, 2026 22:20
… it creates

A communication protocol registered in communication_protocols is one
instance shared by every UtcpClient in the process, and so is any state
it keeps. Right for a credential cache; wrong for connections: a caller
creating a client per tenant, per user or per pooled connection was not
actually isolating them, and no client could tear its own down — the
client had no close() at all.

New registry: communication_protocol_factories, filled through
register_communication_protocol_factory. UtcpClient.create calls a
factory once per client, and the client records the instance as OWNED.
A factory wins over a shared instance of the same type, so a plugin
migrates by moving its registration and callers change nothing. Shared
instances are still looked up live, so late registration keeps working.

Teardown is scoped to what the client owns. UtcpClient.close() (new,
on the interface and the implementation) closes the owned instances and
leaves shared ones to the process — every instance is closed even when
one fails, then the failures are raised together as
UtcpProtocolCloseError. create() adopts the factory instances inside a
cleanup guard: nothing that needs closing is created in the constructor
(it cannot await), a factory that raises part-way leaves the earlier
instances closable, and any initialization failure closes them before
re-raising, a failing cleanup being logged with the original error kept
as the one the caller sees.

CommunicationProtocol.close() is now part of the interface, a no-op by
default, so the client can close any protocol uniformly. All of it
carries REQUIRED docstrings, since the spec is generated from them.

Tests (10, through the public surface): per-client routing, factory over
shared, late shared registration, unknown type names both registries,
close() scoped to own instance, close() waits for every instance,
failed create closes what it created, a raising factory leaves earlier
instances closed, shared survives a failed create, failing cleanup is
reported. Each guard mutation-checked. Core 51/51, http 238, cli 62,
text 11.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The MCP protocol holds live sessions and, for stdio, child processes.
Registered as one shared instance, every client in the process dialled
into one session cache and one client's close() drained everyone's.
It now registers through register_communication_protocol_factory, so
each UtcpClient gets its own instance, its own connections and its own
teardown.

The factory registry is a new core feature, so core goes to 1.2.0 and
utcp-mcp to 1.2.0 requiring utcp>=1.2.0. MCP suite 48/48.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…te factory is adopted on first use

Two findings from cubic on #108.

register_manuals gathered its registrations first-failure-wins: when one
raised UtcpVariableNotFound the caller got the failure while the sibling
registrations were still running underneath it — and create(), which
closes the client's protocols right after, would close them under a
registration still in flight. Every registration now settles before the
first failure is raised, so a caller holding the error holds a quiet
client.

The resolver consulted the factory registry only at creation, so a
factory registered after a client existed was invisible to it while a
shared instance registered late was not. Both registries are now
consulted live: a late factory is adopted on first use, owned and closed
like the rest. A type resolves the same way whenever it was registered.

Tests: register_manuals raises only once every sibling has finished; a
failed create closes its protocols only after every registration has
finished; a late-registered factory is adopted on first use and owned.
Each mutation-checked. Ownership 13/13, core 54/54, MCP 48/48, http 238.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The guidance said a protocol that holds connections registers a
factory, while the WebSocket plugin — one live WebSocket per manual name
and URL — still registered a shared instance: two clients registering
the same manual used, and on deregistration closed, each other's
connection, and one client's close() dropped everyone's. It now
registers through register_communication_protocol_factory, so each
client owns its connections. utcp-websocket goes to 1.2.0 requiring
utcp>=1.2.0. Suite 38/38.

The criterion itself was too blunt. 'Holds connections' would also
sweep in the HTTP plugin's pooled aiohttp session, which is meant to be
shared. The README, the interface docstring and the registry docstring
now say what actually decides it: state that must not be shared between
clients — sessions or connections keyed per manual, child processes,
anything one client's use or close() would take away from another —
goes in a factory; a credential cache or a pooled session stays an
instance. Every shipped plugin is now on the side the criterion puts it:
mcp and websocket are factories; http, sse, streamable_http, gql, cli,
file, text, tcp and udp keep nothing per client and stay shared.

Raised by cubic on #108.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…-client-protocol-factories

core: a protocol can be registered per client, and a client owns what it creates
Four defects in scripts/extract_required_docs.py, each surfacing as a
wrong page in the generated spec (cubic, utcp-specification #66):

- A prose line containing a colon that is not a 'name: description'
  pair was silently DROPPED: '(note: ...)' sentences, quoted URLs after
  '(e.g.', anything with a colon mid-sentence. Such a line is now
  ordinary text.
- Any line ending in a colon became a title-cased section header, code
  spans included ('Inheritance is controlled by `inherit_env_vars`:' ->
  '**Inheritance Is Controlled By `Inherit_Env_Vars`**'; 'def tool1():'
  inside an example -> '**Def Tool1()**'). A header is now a known
  Google-style one or a short title of words, digits, spaces and
  hyphens.
- A docstring with no section header at all was never flushed, so 62
  REQUIRED docstrings across the spec rendered as '*No ... documentation
  available*' (UtcpClient, every auth serializer, the plugin loader, all
  socket methods, ...). The bogus headers above had been flushing some
  of them by accident, which the fix exposed.
- Cross-reference links were inserted inside inline code spans, where
  Markdown shows them as literal brackets. Links now stop at code
  spans; the field-list placeholder backticks are unwrapped before the
  pass so fields keep their links.

Also: a class whose own docstring is not REQUIRED but whose methods are
now renders those methods (the index already counted them), and index
links are POSIX paths on every platform.

Two docstrings corrected on the way: the CLI template claimed tool_args
are 'shell-quoted' - the mechanism is per-invocation environment
variables, as the same docstring explains - and 'OAuth2Auth.cache_key'
is now written so the class links and cache_key stays code.

Regenerated against the published pages: 43 pages change; every removed
line is a placeholder, a bogus header, a link-in-code-span or a mangled
example fragment; 360 lines of previously invisible documentation come
back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three findings from cubic on #110, each a heuristic that was right on
the cases in front of me and wrong one step over:

- The inline-code splitter closed a span at the first backtick run, so
  ``a`b`` ended at the inner tick and class names still inside the span
  were linked. A span now closes only on a run of the same length.
- A bare URL line ("https://example.com") still parsed as a definition:
  "https" is short, alphanumeric and has no space. A definition has a
  space after its colon; a URL has "//". That is the whole difference,
  so it is the rule.
- The header heuristic (short, words only) admitted "Use the following:"
  and rejected "Return Values (Complex):" or anything over 41 chars. A
  custom header is a Title-Cased line ending in a colon: every word
  starts with a capital or a digit, no code span, any length. Sentence-
  case captions ("Basic command step:") now stay under their real
  "Examples" header instead of replacing it.

Unit-checked on the boundary cases; regenerated and compared with the
previous run -- the only movement is captions becoming paragraphs under
the section header they belong to.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ractor-fidelity

docs: the spec extractor renders what the docstrings say
# Conflicts:
#	plugins/communication_protocols/websocket/pyproject.toml

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread README.md Outdated
Comment thread core/src/utcp/implementations/utcp_client_implementation.py Outdated
Comment thread scripts/extract_required_docs.py Outdated
Comment thread core/tests/client/test_client_protocol_ownership.py
…lugins first

Four findings from cubic on the release PR (#111):

- create() rolled back the protocols it created but not the manuals it
  registered: when one manual of a batch fails after a sibling has
  saved, the sibling stayed in the tool repository -- which may be
  caller-supplied and shared. The manuals THIS attempt added are now
  deregistered before the owned protocols are closed. Only this
  attempt's: a manual already present before (whose re-registration
  fails as a duplicate without raising) is left alone. The manual-name
  rule lives in one helper now, so create() computes the same names
  register_manual will use.
- The two registry-isolating fixtures swapped the class-level dicts
  before plugins had loaded. Plugins load lazily on the first create()
  of the session and register into whatever dict holds the attribute at
  that moment -- so a session whose first create() happened inside an
  isolated test wrote every shared instance into the throwaway dict and
  lost it on restore, silently unregistering http/sse/streamable_http
  for the rest of the session. Both fixtures load plugins first.
- The extractor's inline-code scanner ran per line, so a span crossing
  a newline was linked inside. Everything between fences is now one
  segment for the scanner.
- The README and two docstrings said utcp-http/utcp-gql keep a pooled
  HTTP session. They do not: every request opens its own session; the
  only shared state is the OAuth token cache. Said so.

Tests: a manual registered by a failed create() is removed from the
caller's repository; a manual present before the attempt survives it;
the multi-line span case is unit-checked. Mutation-checked. Core 56/56.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
h3xxit and others added 2 commits September 16, 2026 18:04
…protocols even when cancelled

Two findings from cubic on #112, both about the rollback added there.

It inferred "what this attempt registered" from repository state --
names absent before, present after -- which is wrong on a shared
repository: a manual another client registered in the meantime looks
identical to one of ours, and the rollback would delete it. The batch
registration now appends each registered name to a list AS the
registration succeeds (_register_each, shared with register_manuals),
and create() rolls back exactly that list. A name another client took
first fails our registration as a duplicate and is never on the list.

And the cleanup was not cancellation-safe: a CancelledError landing in
the rollback bypassed the close, leaking the factory-created protocols.
The close now runs in a finally after the rollback, so a cancellation
still closes what this client created and then reaches the caller, as
a cancellation must.

Tests: a manual another client registered during the attempt survives
the rollback (the other client registers from inside the batch, so the
interleaving is deterministic); cancelling create() while its rollback
is blocked in deregistration still closes the owned protocol and the
caller sees the CancelledError. Mutation-checked: inferring from the
repository again fails exactly the two "survives" tests; moving the
close out of the finally fails exactly the cancellation test.
Core 59/59.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ase-review-111

core/docs: a failed create() leaves no manual behind; fixtures load plugins first
@h3xxit
h3xxit merged commit 3730c46 into main Sep 16, 2026
19 checks passed
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