Skip to content

Commit e96aea6

Browse files
tclemCopilot
andcommitted
docs(skill): rewrite "Idioms that don't port" with full research backing
stephentoub flagged that the "Idioms that don't port from Go or Node" section was lopsided (no C# / Python mention) and that the cancellation guidance was too absolute (it dismissed `ctx.Done()` analogs without mentioning tokio_util's CancellationToken). Both true. Replaces the section with a thorough rewrite covering all four idioms with citations and decision matrices: 1. Event subscription: keep "channels not callbacks" baseline; add the full primitive matrix (mpsc / oneshot / broadcast / watch); explain that the public API should expose `impl Stream<Item = T>` (the canonical IObservable<T> analog), citing tonic / reqwest / sqlx as precedent. 2. Cancellation: rewrite. "Drop = cancel" is the primitive for caller-owned futures. `tokio_util::sync::CancellationToken` is the canonical answer for SDK-internal task coordination — citations to tonic's cancellation example, tokio-util docs, withoutboats blog post, and Cybernetist's task cancellation patterns survey. Notes the SDK's own `Session.shutdown: CancellationToken` field and the `Session::cancellation_token()` accessor. 3. Optional fields: confirm Option<T> + Default baseline; add non_exhaustive + builder pairing (AWS SDK convention); note when typestate / Result-from-build is the right answer. 4. serde JSON: confirm rename_all + per-field rename baseline; add skip_serializing_if = "Option::is_none" for output omission and #[serde(default)] for input tolerance (LSP/JSON-RPC convention). Section title is also de-language-coupled: "Idioms that don't port from other languages" instead of "...from Go or Node". Lists Node / Python / C# / Go equivalents inline where pattern divergence is notable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7202dfd commit e96aea6

1 file changed

Lines changed: 107 additions & 16 deletions

File tree

  • .github/skills/rust-coding-skill

.github/skills/rust-coding-skill/SKILL.md

Lines changed: 107 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -125,22 +125,113 @@ Log with structured fields: `info!(session_id = %id, "Session created")`.
125125
Static messages stay greppable; dynamic data goes in named fields, not
126126
interpolated into the message string.
127127

128-
## Idioms that don't port from Go or Node
129-
130-
The most common pitfall when adapting code from the Node and Go SDKs is the
131-
event subscription pattern. Those SDKs expose `client.on(handler)` callback
132-
registration; the Rust SDK uses typed channels (`tokio::sync::broadcast` for
133-
fan-out, `tokio::sync::mpsc` for single-consumer streams). Don't try to
134-
recreate observer-style callbacks — drop the consumer onto a channel and let
135-
each subscriber `.recv()` on its own task. See `Session::events_subscribe()` for
136-
the canonical example.
137-
138-
Similarly, contexts and cancellation in Go/Node map to dropping a future or
139-
calling `JoinHandle::abort()` — there is no `ctx.Done()` analogue to plumb
140-
through every call site. Optional fields use `Option<T>`, not nullable
141-
pointers; defaults come from `Default` impls, not constructors that accept
142-
zero values. JSON tag attributes become `#[serde(rename_all = "camelCase")]` at
143-
the type level plus `#[serde(rename = "…")]` on the occasional outlier.
128+
## Idioms that don't port from other languages
129+
130+
When porting code from the Node, Python, Go, .NET, or any other SDK,
131+
four idioms reliably translate poorly into idiomatic Rust. Each has
132+
specific guidance:
133+
134+
### Event subscription: channels (and `Stream`), not callbacks
135+
136+
Other SDKs expose callback registration:
137+
138+
- Node / Python: `client.on('event', handler)` / `add_listener`
139+
- C#: `event` declarations and `+= handler`, or `IObservable<T>`
140+
- Go: `for ev := range ch { ... }` (closer to Rust already)
141+
142+
Rust's async ecosystem prefers explicit channels over callback closures
143+
because closures fight `Send + Sync + 'static` and don't compose with
144+
`select!`/`StreamExt`. Pick the channel type by semantics:
145+
146+
| Use case | Primitive |
147+
|---|---|
148+
| One producer → one consumer with backpressure | `tokio::sync::mpsc` (cap 1) or `tokio::sync::oneshot` for single value |
149+
| Many producers → one consumer (work queue, command bus) | `tokio::sync::mpsc` |
150+
| One producer → many consumers, every event delivered (pub/sub) | `tokio::sync::broadcast` |
151+
| One producer → many consumers, only the **latest** value matters (current state) | `tokio::sync::watch` |
152+
153+
For the **public** API, prefer returning `impl Stream<Item = Event>`
154+
(typically by wrapping a `broadcast::Receiver` in
155+
`tokio_stream::wrappers::BroadcastStream`). `Stream` is the canonical
156+
"observable" shape in Rust — it composes with `select!`, `take`, `map`,
157+
`filter`, `timeout`, etc. Internally use a channel; externally consider
158+
exposing a `Stream`. This is what `tonic`, `reqwest::Response::bytes_stream`,
159+
and `sqlx::query::fetch` expose. See `EventSubscription` and
160+
`LifecycleSubscription` for the canonical examples in this crate.
161+
162+
`Fn`-callback registration (`on_event(handler)`) is not an outright
163+
anti-pattern — `notify` (the FS watcher) and `bevy` use it idiomatically
164+
for non-async / domain-specific contexts — but for an async SDK exposing
165+
events to user code, channels + `Stream` is the canonical shape.
166+
167+
### Cancellation: drop is the primitive; `CancellationToken` for SDK-internal coordination
168+
169+
Cancellation does NOT plumb through every call site like Go's
170+
`context.Context` or .NET's `CancellationToken`. Two distinct cases,
171+
both idiomatic:
172+
173+
**1. Caller-owned futures (`send_message`, `send_and_wait`, subscription streams).**
174+
Drop the future / `select!` it out / wrap in `tokio::time::timeout`.
175+
The caller already has full lifecycle control via the value's lifetime;
176+
adding a token parameter just duplicates what `select!`/`timeout`/drop
177+
already provide. This is what `reqwest`, `sqlx`, the `aws-sdk-*` crates,
178+
and `tonic`'s client side do. **Don't accept a token here.**
179+
180+
Document cancel-safety on every `.await` in the SDK's hot path the way
181+
[`tokio` itself does](https://docs.rs/tokio/latest/tokio/macro.select.html#cancellation-safety):
182+
state explicitly which operations are safe to cancel mid-flight and
183+
which are not.
184+
185+
**2. SDK-internal task coordination (event loops, subprocess readers,
186+
spawned background tasks).** Use [`tokio_util::sync::CancellationToken`].
187+
This is the canonical Rust analog to Go's `ctx.Done()` and .NET's
188+
`CancellationToken`, but scoped to where it actually belongs: tasks the
189+
caller doesn't own. `tonic` uses it to propagate client-disconnect into
190+
spawned server handlers; `tokio-graceful-shutdown` builds a whole
191+
hierarchical-shutdown framework on it. The token's parent/child tree
192+
maps cleanly onto session/request scoping.
193+
194+
In this SDK, `Session.shutdown: CancellationToken` ties the event loop
195+
and any spawned helpers to the session's lifetime. `Drop for Session`
196+
calls `cancel()`. Power users can call
197+
`Session::cancellation_token() -> CancellationToken` to get a child
198+
token and bind their own work to the session lifetime via `select!`.
199+
Cancelling the child does NOT cancel the parent — child cancellation is
200+
isolated by design.
201+
202+
**Citations**: [`tokio_util::sync::CancellationToken` docs][ctoken]
203+
([`tonic` cancellation example][tonic-cancel]),
204+
[withoutboats: "Asynchronous clean-up"][wb-cleanup],
205+
[Cybernetist: "Rust tokio task cancellation patterns"][cybernetist].
206+
207+
[ctoken]: https://docs.rs/tokio-util/latest/tokio_util/sync/struct.CancellationToken.html
208+
[tonic-cancel]: https://github.com/hyperium/tonic/blob/master/examples/src/cancellation/server.rs
209+
[wb-cleanup]: https://without.boats/blog/asynchronous-clean-up/
210+
[cybernetist]: https://cybernetist.com/2024/04/19/rust-tokio-task-cancellation-patterns/
211+
212+
### Optional fields: `Option<T>`, not nullable pointers or zero values
213+
214+
`Option<T>`, not nullable references or "empty string means missing"
215+
sentinels. Defaults come from `Default` impls, not from constructors
216+
that accept zero values. Pair with `#[non_exhaustive]` on public config
217+
structs and a builder so adding fields stays non-breaking — this is the
218+
AWS SDK convention. If the SDK has *required* builder fields and you
219+
want compile-time enforcement of `.build()` validity, prefer
220+
`build() -> Result<Self, BuildError>` over typestate unless the
221+
required-field count is tiny (1-2). Typestate is overkill for plain
222+
optional fields.
223+
224+
### serde JSON: container `rename_all` plus per-field overrides
225+
226+
JSON tag attributes become `#[serde(rename_all = "camelCase")]` at the
227+
type level, with per-field `#[serde(rename = "…")]` overrides for
228+
outliers. For optional output fields use
229+
`#[serde(skip_serializing_if = "Option::is_none")]` to omit unset
230+
values from the wire (the JSON-RPC convention this SDK follows
231+
matches LSP's). Use `#[serde(default)]` for forward/backward-compatible
232+
input. `serde_with` is the right escape hatch for non-trivial transforms
233+
(durations, base64, numeric-as-string keys); reach for it as needed,
234+
not by default.
144235

145236
## Code organization
146237

0 commit comments

Comments
 (0)