Skip to content

cancellation: Hook up libuv to cancellation - #62557

Merged
Keno merged 18 commits into
JuliaLang:masterfrom
KenoAIStaging:kf/uv-cancel
Aug 5, 2026
Merged

cancellation: Hook up libuv to cancellation#62557
Keno merged 18 commits into
JuliaLang:masterfrom
KenoAIStaging:kf/uv-cancel

Conversation

@Keno

@Keno Keno commented Jul 29, 2026

Copy link
Copy Markdown
Member

Extracted from #60281. Extends the wait protocol from #62430 with support for multiple simultaneous wait queues and uses this to wait for both cancellation and libuv completion at the same time.

@Keno
Keno force-pushed the kf/uv-cancel branch 5 times, most recently from 62d1d0c to 2077a08 Compare July 30, 2026 05:24
Comment thread src/julia_threads.h Outdated
// path (they stay registered across parks and are collected by walks) -
// interior links are rewritten only under `walk_lock`. See the
// registration protocol in base/cancellation.jl.
_Atomic(jl_value_t*) waiters_head; // Union{Nothing, Base.WaitEntry2}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not required to be WaitEntry2 - any WaitEntry will do.

Comment thread src/julia_threads.h Outdated
// Serializes cancellation/prune walks against each other (never taken
// by registration or wakeup); attachment and state stay lock-free (see
// the concurrency notes above).
_Atomic(uint8_t) walk_lock;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Needs a lock yes, but should be a sleeping lock

Comment thread src/julia_threads.h Outdated
// Registrations pushed since the last walk (approximate); a pusher that
// trips the threshold runs a pruning walk to unlink registrations of
// completed tasks.
_Atomic(uint32_t) push_count;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Reasonable, but we can just keep track of how many dead ones there actually are.

Comment thread src/julia_threads.h
// cancellable park under a source pays a registration. Owned by this
// task; kept separate from `cached_wait_entry` so that an entry linked
// on a source is only ever armed for waits governed by that source.
jl_value_t *cached_cancel_entry;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Just always use the WaitEntry2, we don't need two separate caches

Comment thread base/strings/io.jl Outdated
write(io::IO, s::Union{String,SubString{String}}) =
GC.@preserve s (unsafe_write(io, pointer(s), reinterpret(UInt, sizeof(s))) % Int)::Int
write(io::IO, s::Union{String,SubString{String}}; cancel::CancelTokenArg=DEFAULT_CANCEL) =
_with_cancel_arg(() -> GC.@preserve(s, (unsafe_write(io, pointer(s), reinterpret(UInt, sizeof(s))) % Int)::Int), cancel)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I don't like this _with_cancel_arg pattern. The token should be resolved once at the highest entry point and then just passed through as the kwarg so the inner code doesn't have to look at the ScopedValue again.

Comment thread base/cancellation.jl
# scanning for its own identity (`_find_slot`), so an entry may be
# registered on several waitables at once (wait-any). At most one slot per
# owner per entry.
_nslots(w::WaitEntry1) = 1

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Let's have a WaitSlotRef{T<:Union{WE1,WE2,WEN}} with getproperty/setproperty for owner/next/aux and treat the WaitEntry as AbstractVector{WaitSlotRef{WE}}. I think that will make the code read better.

Comment thread base/stream.jl Outdated
uv_error("shutdown", err)
end
ct = current_task()
if abandoning_external_waits()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

All of these need to be tied to the cancellation src

Comment thread base/stream.jl Outdated
iolock_end()
checkcancel(src)
end
if abandoning_external_waits()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

If we're already canceled, it doesn't make sense to issue the write

Comment thread base/stream.jl Outdated
catch err
# (catch restored the sigatomic level from the try entry; the
# teardown helper unwinds it)
_uv_write_cancelled_teardown!(s, w, uvw, err, src, ct, owner)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I don't like doing this via try/catch. Cancellation is not necessarily an unexpected situation. Also, for write in particular, the caller needs to be able to handle partial writes, so we can't throw here.

@Keno
Keno force-pushed the kf/uv-cancel branch 4 times, most recently from 1aa0b03 to 330b0d2 Compare July 31, 2026 06:25
@Keno Keno changed the title WIP: cancellation: Hook up libuv to cancellation cancellation: Hook up libuv to cancellation Jul 31, 2026
@Keno
Keno marked this pull request as ready for review July 31, 2026 22:47
@Keno
Keno requested review from d-netto, qinsoon and udesou as code owners July 31, 2026 22:47
Comment thread base/stream.jl Outdated
@Keno

Keno commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

This is getting there, I think.

@Keno Keno added the needs pkgeval Tests for all registered packages should be run with this change label Aug 3, 2026
@Keno

Keno commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

I think it's basically ready to go, but we probably need to pkgeval it, since I'm assuming packages are reaching into the wait internals in all sorts of horrible ways.

@Keno

Keno commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

@pkgeval runtests()

@pkgeval

pkgeval commented Aug 4, 2026

Copy link
Copy Markdown

@Keno: run gh-5173963448 finished — possible new issues: 195 packages ❌

Full report: https://pkgeval-reports.julialang.org/?run=gh-5173963448
Estimated compute cost: $40.11 (EC2 spot)

Keno and others added 7 commits August 4, 2026 17:36
Give every CancellationTokenSource the storage for a registry of
parked-waiter registrations (Base.WaitEntry cancellation slots), through
which the cancellation walk will find and wake tasks blocked under the
source: a lock-free intrusive LIFO (`waiters_head`), a sleeping
`walk_lock` (a lazily-installed ReentrantLock - sources that are never
walked stay small) that serializes only walks (delivery, pruning,
owner-side unregistration) against each other - no park or wakeup path
ever takes it - and a `dead_count` of collectable registrations feeding
the prune heuristic. Like attachment and state, registration is
lock-free.

The variable-sized "many" wait-entry kind (`Core.WaitEntryN`) joins the
runtime alongside it: `nslots` uniform {owner, next, aux} wait slots
following the fixed fields, with the same variable-sized-object treatment
as the source (small-typeof tag, special-cased stock-GC marking and MMTk
scanning, reset-on-write image serialization, egal-by-identity) plus
slot accessors for the Julia side (slot owners are atomic, accessed
relaxed: identity scans read them from threads that do not hold the
slot's protecting lock). The 1- and 2-slot kinds stay ordinary Julia
structs; this kind serves wait-any over arbitrarily many waitables.

The new fixed fields ride along through the full variable-sized-layout
discipline: the Julia-visible field table (jltypes.c), system-image
serialization (which resets the transient runtime state on write), the
stock GC's special-cased marking (the head slot is strong), and the MMTk
object scanner.

The generic runtime knows the new kind where variable-sized objects need
special-casing: `deepcopy`/`Serialization` reconstruct through the
allocator (a generic fixed-size copy would corrupt the heap),
`Core.sizeof`/`summarysize` account the slot tail, the serialization
queue skips the transient fields, and the allocator rejects slot counts
whose allocation size could overflow.

The task side gains a second per-task entry cache
(`Task.cached_cancel_entry`) alongside the existing plain-park cache:
plain (shielded) and cancellable parks arm distinct entries, because an
entry registered on a source's waiter list must never be armed for a
wait that is not cancellable under it - the walk's expected-entry claim
CAS is its only sound eligibility gate (see the delivery commit).

This is purely the storage layer; the registration protocol and the
delivery walk that consume it land separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wire the cancellation walk up to blocked tasks, and make every wait
registration uniform over the number of things it waits for. An entry is
a set of {owner, next, aux} slots - `owner` doubling as the "am I
registered, and on what" membership witness - and comes in kinds by slot
count: the 1-slot `WaitEntry1` serves plain parks, cancellable parks use
the 2-slot `WaitEntry2` (whose second slot registers the wait on the
governing source's waiter list, with the minimum delivery severity in
its aux word), and `Core.WaitEntryN` covers wait-any over arbitrarily
many waitables. The Julia kinds are defined mutually recursively in a
`typegroup`; `WaitEntry` becomes the union of all kinds, and every list
algorithm works through the slot accessors: lists link whole entries,
and a traversal locates its slot in each entry by scanning for its own
identity, so one entry can be registered on several waitables at once.
To give traversals that identity, each wait queue's identity is now
canonically the condition itself (`waitqueue`); the `waitee` relaying
that used to thread through `wait`/`_wait2` is gone.

`cancel!` claims and wakes registered waiters by scheduling the
CancellationRequest as an exception, following the same wake-claim
protocol as `notify` and interrupters. The walk's deferred wakes are
claim-scoped (`deliver_claimed_wake!`): the claim CAS was the wake
ticket, so the delivery never re-claims the way an interrupter's
unconditional swap does, and it is dropped when the task re-registered
meanwhile - a still-eligible re-park is refused by its own registration
recheck, and anything else must not observe the request. For the same
reason the interrupted-wait cleanup drops a claimed-and-enqueued wake
only *after* its relock: the lock round-trip serializes against a
notifier's in-flight claim-and-schedule, making the drop deterministic
(the uv request teardown does the same under the iolock).

The registration is sticky, and every hot path is lock-free, extending
the wait protocol's lazy-corpse philosophy to the source list:

  - The first cancellable park under a source publishes the entry with a
    CAS push and closes the arm-vs-cancel race by re-reading the state
    afterwards - the same seq_cst publish-then-recheck dance the source
    already uses for lock-free child attachment. Registration on an
    already-cancelled source is refused: the waiter throws instead of
    parking.
  - A normal wakeup does no registry work at all, and a repeat park
    under the same source re-arms the already-registered entry with no
    shared-memory operation. `min_severity` admits teardown waits that
    re-park until a severity escalation; the re-park is a plain re-arm.
  - Only walks rewrite links, serialized by the source's sleeping
    `walk_lock` (which no park or wakeup takes; the acquire is shielded,
    since a walk may deliver the very cancellation governing the walking
    task): the cancellation walk collects the registrations of completed
    tasks as it delivers, and dead registrations - counted where they
    die, at entry retirement and task teardown - trigger the same
    collection as a prune on sources that never get cancelled. The one
    eager unregistration left is owner-side rebinding of the task's
    cached entry when its governing token changes.
  - Plain (shielded) and cancellable parks arm distinct cached entries
    (a WaitEntry1 never registered on any source, and the WaitEntry2
    carrying the sticky source slot). This is what keeps expected-entry
    claims sound: an entry linked on a source is only ever armed for
    waits cancellable under it, so the walk's claim CAS structurally
    cannot land on a shielded park. Eligibility data outside the claim
    word - the aux severity floors - is read racily by the walk and can
    be judged against an adjacent arm of the same entry; that misfire is
    tolerable for a teardown wait's floor (a spurious below-floor wake
    into a wait that already handles interruption conservatively), never
    for a shield, which is why shields get their own entry identity
    rather than a staged floor.

The wait layer resolves the governing token from the new `cancel`
keyword argument of `wait(::GenericCondition)` (defaulting to the scoped
`CANCEL_TOKEN`, with `nothing` making a wait non-cancellable), and the
core blocking primitives pass it through: task waits, `lock`,
`sleep`/Timer/AsyncCondition waits. Cleanup paths that must not be
interrupted shield themselves - the contended-lock reacquire after a
delivered cancellation, handle close waits, and the Timer/AsyncCondition
callback tasks that own their handle's lifetime.

Cancellation of *running* tasks (asynchronous delivery to compiled
cancellation points) is not part of this change; `cancel!` currently
wakes parked waiters only, and an ABANDON_ALL request also delivers by
interruption until the freeze/escalation machinery lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Make blocked stream writes and closewrite (shutdown) waits cancellable.
The waiter parks through its WaitEntry (the uv request's data field now
points at the entry rather than the task), registered on the governing
source like any other cancellable wait; the completion callbacks claim
the wake through the standard protocol, so a cancellation and a
completion race safely.

A cancelled write asks libuv to cancel the request (`uv_cancel`, new in
the bundled libuv along with `uv_write_nwritten`). For SAFE severities
the waiter then re-parks (woken only by a severity escalation) until the
completion callback reports the partial write count, so the caller's
buffer is provably no longer in use by the OS when the wait unwinds. At
the abandoning severities - and whenever the waiter cannot wait for the
callback - the request is instead *detached*: the completion callback
owns freeing it, and the written buffer's Julia owner is kept rooted in
a registry keyed by the request until then. Shutdown requests cannot be
cancelled; an interrupted closewrite wait always detaches. Under an
already-ABANDON_EXTERNAL-cancelled scope, writes and shutdowns are
issued fire-and-forget and stream close waits do not park at all.

The owner-carrying entry points (`write(::LibuvStream, ::Vector{UInt8})`
and the internal `_uv_write_owned`/`_unsafe_write_owned` chain) pass the
buffer positionally with `@nospecialize` so the chain stays statically
resolvable for trimmed builds; raw-pointer `unsafe_write` keeps its
pointer-validity contract and detaching it retains the pre-existing
hazard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Give the documented blocking operations an explicit `cancel` keyword
argument: the generic and libuv-backed IO read/write surface, `Channel`
operations, `Base.Event`, `Semaphore`, `lock(f, ::ReentrantLock)`,
process running and waiting, `waitany`/`waitall`,
`Experimental.wait_with_timeout`, and the blocking entry points of
Sockets (connect/accept/recv/recvfrom/send and the getaddrinfo family)
and FileWatching.

The keyword defaults to a sentinel meaning "use the scoped
`CANCEL_TOKEN`", so the fast paths pay nothing; a token is resolved
once, at the operation's entry point - an explicit one is checked there,
before the operation has any side effect - and passed through
explicitly: positionally on internal chains (which keeps them statically
resolvable for trimmed builds), or as the `cancel` keyword of inner
operations that take one. Where a composite operation crosses generic,
user-extensible methods without a `cancel` keyword, the explicit token
gates between the steps (`@cancel_check tok`) and the inner parks are
governed by the ambient scope; the scoped default therefore behaves
identically everywhere, while an explicit token governs exactly the
waits it is threaded into. `cancel = nothing` makes an operation
explicitly non-cancellable, shadowing an outer token.

The DNS lookups (getaddrinfo/getnameinfo) and UDP sends convert from
raw task-pointer request ownership to the WaitEntry protocol, with
cancelled lookups handed back to libuv via `uv_cancel` and uncancellable
in-flight requests detached to their completion callback (keeping a UDP
send's message rooted until then). Process spawns check for cancellation
before the child is created. `_atexit` now disarms a stale wait
registration and runs the exit hooks in a shielded scope, since a
pending cancellation of the exiting task's scope is moot and would only
disrupt teardown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the waiter-task-per-task machinery of `waitany`/`waitall` (one
spawned task plus an unbounded Channel per call) with a single
`Core.WaitEntryN` carrying one slot per waited task plus a slot for the
governing cancellation source. Each completion notify claims the entry
through the standard wake-claim protocol; the entry stays registered on
the still-pending tasks across re-parks, so `waitall` re-arms it instead
of re-registering after every completion, and cancellation of the
current scope interrupts the wait through its source slot like any other
cancellable park. Duplicate tasks share their slot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ords

Add the delivery-side cancellation tests: the waiter-registry protocol
(sticky registration and re-arm, shield staging on the shared cached
entry, token-migration rebinding, pruning of completed tasks' and
retired single-use entries, registration racing cancellation, and
multi-slot wait-any entries),
cancellation of parked waits (sleep/Channel/task/lock/condition and the
stdlib waits), level-triggered redelivery and shielding, `min_severity`
teardown re-parks, blocked stream write and closewrite cancellation
across the severities (cancelled writes return their partial byte count,
with delivery at the next cancellation point), and the `cancel` keyword
argument surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every parked wait now runs one driver (base/park.jl): check the wait
conditions, acquire the entry, arm, enqueue into all the wait queues,
recheck, suspend. The driver is generic over a flat iterable of
*waitables* - an open protocol (`wait_enqueue!`/`wait_recheck`/
`wait_fired`/`wait_dequeue!`, plus release/reacquire brackets around the
suspend) whose dispatch is carried by each call site's concrete
container type.

The cancellation source is an ordinary waitable: `register_cancellation!`'s
halves become `SourceWait`'s enqueue (the sticky lock-free push, or the
seq_cst re-arm fence) and recheck (the seq_cst state read closing the
arm-vs-cancel race), and the refusal is its throwing fired outcome under
the driver's generic fired path (recheck -> self-claim -> withdraw ->
deliver). Entry acquisition is the cache contract: the two canonical
shapes (sole waitee; waitee + source) reuse the task's cached entries,
and any other waitable set gets a fresh, single-use entry - which is
exactly what makes specific-wait wakers sound, since entry identity
scopes their expected-entry claim CAS to the wait it was created for.

Converted onto the driver: condition waits (`wait(c; cancel)`), the lock
slow path (`wait_no_relock`, the driver's no-relock policy), the
standard libuv request waits (`UvReqWait`, whose dequeue is the request
ownership handoff), and `waitany`/`waitall` - per-task `DoneWait`s whose
enqueue declines when the task is already done and whose recheck is
membership-qualified (done AND the slot witness still set), so the
`repark!` loop can own the arm while the caller's bookkeeping runs
unarmed, and leaving a wait is a plain `withdraw!` with no disarm.
`Experimental.wait_with_timeout` becomes a park over a `TimeoutWait`
deadline whose enqueue (running after the arm, where the old code had to
place its spawn by hand) starts the claimer; the fresh-entry contract
replaces its bespoke single-use-entry reasoning, and `_wait2`'s `entry`
escape hatch is gone. The interrupted-wait cleanup - disarm, reacquire,
unlink, then the deterministic pending-wake drop - now exists exactly
once, in the driver.

The blocked-write path (`_uv_write_wait`) intentionally stays a manual
composition of the protocol: its cancelled outcome resolves the
in-flight request per severity rather than throwing a refusal, which is
a policy the generic fired path deliberately does not model.

The driver is the parker process of the TLA+ model of the claim
protocol (tla/WaitClaim.tla in the design records): its control flow,
the registration Dekker, shield integrity under the split entry caches,
and the multi-wait loop's rechecks are model-checked.

This commit was written with the assistance of generative AI (Claude).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Keno and others added 11 commits August 4, 2026 17:36
A cancelled `write` previously returned its partial byte count, with the
CancellationRequest left to the caller's next cancellation point - the
interrupted-POSIX-write shape. Most `write` callers are not prepared to
observe short counts, so that shape is now opt-in: `write` (and
`unsafe_write`) throw the CancellationRequest once the in-flight request
has been resolved for the delivered severity (SAFE awaits the completion
callback, so the buffer is provably out of OS hands; abandoning
severities detach the request with the buffer kept rooted); bytes
already accepted stay written. The new exported generic `writepartial`
carries the old contract - it returns the number of bytes the stream
accepted and leaves the (level-triggered) delivery to the next
cancellation point - and falls back to `write` for IO types whose
writes cannot block on cancellable resources.

`flush` (and the buffered-write flush splice) still never discards: a
cancelled flush write is issued with partial semantics internally, the
unwritten tail is spliced back into the send buffer for a later flush
to retry, and the cancellation is then thrown.

This commit was written with the assistance of generative AI (Claude).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The `cancel` keyword docstrings reference the cancellation types and
constants, but the API had no manual section, so Documenter's
cross-reference check failed on every rendered `[`CancellationToken`](@ref)`
and `[`CancellationRequest`](@ref)`. Add a Cancellation section to the
tasks page documenting the token-source API, and list `writepartial`
beside `write`.

Fixes the docs step of https://buildkite.com/julialang/julia-pr/builds/1118

This commit was written with the assistance of generative AI (Claude).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rotocol

The write specialist composed the source registration through the
retired `Bool`-returning helper. Both of its uses know their severity
floor explicitly (0x00 for the first park; the escalated teardown floor
for the re-park), so they spell the protocol directly - wait_enqueue!,
wait_recheck, and the self-claim - and the helper's merged shape is gone
along with its name.

This commit was written with the assistance of generative AI (Claude).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The driver's fired path carried a throwing/returning kind trait
(`wait_fired_throws`) so a fired source could throw its refusal from
inside `park!`. That distinction does not pull its weight: the driver
now uniformly self-claims, dequeues, and returns `nothing`, and the
caller re-derives what happened from its own conditions - for a
source-governed park, the same `checkcancel` its entry check already
makes, so the refusal throw lives at the call site, symmetric with the
entry check. `wait_fired`, the trait, the REFUSED why-code and the
refusal-delivery helper are gone.

Who dequeues on fired is the caller-lifecycle split rather than a kind
property: the one-shot/owning form dequeues every registration under
the still-held phase-4 protection (a cached entry left linked would
corrupt its reuse), while the multi-wait loop dequeues the fired slot
only and owns the rest; its caller gates each iteration on
`iscancelled` and withdraws before delivering. One sharpening rides
along: a nothing-valued notify consumed under an already-cancelled
source now delivers at the wait itself - a wait is a cancellation
point, and delivery is level-triggered either way.

This commit was written with the assistance of generative AI (Claude).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`park!(ws, w, first)` is now a Bool - phases 3-5 only: arm, enqueue,
recheck, with `true` meaning parked (a wake is in flight; suspend
through `wait_safe_interrupt` to consume it) and `false` meaning a
waitable fired and the self-claim won. The driver takes and releases no
locks: lock choreography is plain caller code, so the release/reacquire
protocol hooks, the relock policy, and the fired-dequeue policy flag are
gone, and the wake-payload ambiguity is structural rather than
sentinel-patched - a payload (any value, `nothing` included) only ever
comes out of `wait_safe_interrupt`.

`wait_safe_interrupt` is the only legal way to suspend on an armed park:
it consumes one wake, and on an exceptional resume runs the whole
interrupted cleanup - disarm, cache-blank, per-kind *self-protecting*
dequeues whose lock round-trips serialize in-flight claimers, then the
deterministic pending-wake drop, then cache restore/retire - before
rethrowing. Site catches owe nothing to the protocol; they only restore
their own callers' lock contracts, reacquiring shielded and only what
that contract demands (`wait(c)` must exit lock-held for its callers'
`finally unlock`; `wait_no_relock` and the uv sites reacquire nothing,
so a cancellation unwind does not sleep on locks it does not need).

Registrations always move through withdraw variants rather than raw
list operations: the lazy settle is `withdraw!(ws, w, WAKE_VALUE)`, the
fired branch `withdraw!(ws, w, WAKE_FIRED)` (both under the caller's
held locks; `wait_dequeue!`'s cleanup/withdraw whys self-protect,
shielded), and entry release rides every exit - fresh entries retire
idempotently, structurally closing the leak the pruning test caught.
The saved lock state is `lockstate` - "token" belongs to cancellation.

This commit was written with the assistance of generative AI (Claude).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On the exceptional path out of the internal wait layer, relocking the
condition's lock only for the caller's `finally unlock` to release it
again was a pointless acquire/release pair - and worse, a cancellation
unwind sleeping on a contended lock it is about to drop. The internal
`wait(c, tok)` now throws having consumed the waiting frame's own lock
level: entry-check and refusal throws `unlock` one level, and the
suspend path's catch restores the *enclosing* frames' hold through the
new `relockall_but_one` (an internal generic in the
`unlockall`/`relockall` family that keeps the saved state opaque; a
no-op for depth-1 holds, so the common unwind performs no lock
operation at all, while nested holders keep their invariant levels).
The saved state is called `lockstate` throughout - "token" belongs to
the cancellation tokens.

Internal wait sites adopt the `locked && unlock` finally idiom (the
waiting frame skips its unlock after a wait-throw; enclosing frames'
unconditional finallys remain correct), with sites that have real
under-lock unwind work reacquiring explicitly and shielded: the Channel
puts' eager-increment rollback, the LibuvStream teardowns' queue/
throttle reads, the UDP receive-stop mirror, and the polling-file-
watcher timer cleanup - which also had its reacquired iolock leak past
the rethrow, starving the event loop process-wide once a poll was
cancelled. Converted sites: Channel operations, `Base.Event`,
`Semaphore`, task `_wait`, `Process` exit waits, Timer/AsyncCondition
`_trywait` and close waits, the LibuvStream/BufferStream stream waits,
`wait_close`, and the Sockets `accept`/`wait_connected`/`recvfrom` and
FileWatching watcher waits.

The exported `wait(c::GenericCondition; cancel)` keeps its public
contract - exceptional exits hold the lock - through a shim that
restores one shielded level in its catch; it asserts the caller
contract itself first, so a violation (not locked, or locked by another
task) propagates with the lock state untouched instead of the restore
acquiring a lock the caller never held. `wait_no_relock` needs no
restore anywhere, per its name.

Also fixes two CI breakages from the previous push: the `unlock`
docstring had been orphaned onto a helper inserted between it and its
function (the docs cross-reference check failed tree-wide), and
`jl_new_wait_entry` was missing its `JL_CANSAFEPOINT` annotation
(analyzegc).

This commit was written with the assistance of generative AI (Claude).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ource

The two `_wait2` variants survived every park conversion because they
are not waits: they subscribe a freshly constructed, never-started task
to a condition's (or a task-completion's) next notify, whose
claim-and-schedule is the subscribed task's *first* schedule - a start
trigger; the current task never suspends. The name now says so, and the
fresh-task requirement is enforced as never started, scheduled, or
armed: a task that `@async` has merely queued still arms successfully,
but its own first park then collides with the foreign registration -
the arm failure unwinds that wait's lock choreography from the outside
and leaks the waited object's lock (a leaked cond SpinLock wedges the
process at the next touch, e.g. a sleep timer's close). The internal
condition wait now also consumes its frame's lock level on any throw
out of the park phases, so even a future contract violation errors
cleanly instead of wedging. The contract is documented at the
definition: subscriptions are not parks; waits of the current task go
through `park!`.

Subscriptions are now governed by the *waiter's* birth cancellation
source - the CANCEL_TOKEN of the scope captured at its construction:
tasks inherit a cancellation source from birth, and if that source is
cancelled while the task is still subscribed (or already at subscribe
time), the task dies with the CancellationRequest instead of starting.
Mechanically this reuses the park machinery for another task: the
subscription arms the waiter's sticky cancel entry, registers it on the
source with the same publish-then-recheck dance as a park's phases 4-5
(claiming back the waiter's arm on refusal), and the cancellation
walk's claim-scoped delivery kills a never-started task by raising the
request at start.

Every current in-tree subscriber is cleanup-class and is therefore
constructed shielded, preserving its behavior: the Timer/AsyncCondition
callback tasks already were; the channel `bind` close hook (a bound
channel must close - releasing its blocked users - even when the scope
that bound it is cancelled), `errormonitor`'s reporter (a cancelled
scope still gets its failure report), and the REPL teardown task gain
explicit `CANCEL_TOKEN => nothing` construction. The
die-with-birth-source behavior is the default contract for future
subscribers, parallel to parks being cancellable by default with
`cancel = nothing` as the opt-out.

A sweep for machinery orphaned by the park-driver rounds found none:
the registration, refusal, fired-path, cleanup and bracket helpers
removed across those rounds have no remaining references.

This commit was written with the assistance of generative AI (Claude).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Since the write layer routes owner-carrying writes (String/Array)
directly into `_unsafe_write_owned`, the sysimage workload session no
longer compiles the raw-pointer `unsafe_write` positional entries on
its own, and their statements silently dropped out of the image while
interactive startup still reaches them through generic IO plumbing.
REPL/test/precompilation.jl then fails on every platform with exactly
these two statements traced at startup. Pin them as hardcoded
precompile statements, per that test's guidance.

Fixes the Test-group failure in
https://buildkite.com/julialang/julia-pr/builds/1136

This commit was written with the assistance of generative AI (Claude).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ests

A write large enough to be split into multiple OS-sized chunk requests
could not really be cancelled: our libuv cancellation dequeues the
targeted request out of order, so cancelling the awaited last chunk
said nothing about the earlier ones still queued against the same
buffer, and a SAFE cancellation therefore drained the entire remaining
write before it was honored - on a slow sink, arbitrarily long after
the request.

Split writes now count down their pending completion callbacks on the
wait entry's witness-slot aux (all readers and writers of which run
under the iolock): every chunk request points at the wait entry,
intermediate completions decrement - recording the first real error,
which must win the final wake over the close-induced UV_ECANCELED
cascade - and only the callback that reaches zero claims and wakes the
waiter. Cancellation marks the sweep on the same aux word and
`uv_cancel`s every in-flight request tail-first: the chunks sit
contiguously in the stream's queue, so cancelling from the tail
dequeues behind the still-active head and the wire always keeps a
clean prefix. The waiter owns and frees every chunk request, which
makes the reported count exact - completed chunks plus the cancelled
head's partial - rather than the previous "last chunk or nothing";
abandoning severities detach whatever is still in flight with the
buffer kept rooted until each detached callback has run. The
single-request path is unchanged (a pending count of zero).

This commit was written with the assistance of generative AI (Claude).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A comments-only pass over the additions of this PR: deduplicated
against the definitions, trimmed to the final design (no references to
intermediate states or out-of-tree artifacts), and two stale
cross-references corrected.

This commit was written with the assistance of generative AI (Claude).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The OS pipe buffer's quota is advisory and grows, so the whole write
can complete before the cancellation lands; the sweep then settles the
completed requests in full and the reported count equals the request.
Fixes the Windows Test failure in
https://buildkite.com/julialang/julia-pr/builds/1167

This commit was written with the assistance of generative AI (Claude).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Keno Keno removed the needs pkgeval Tests for all registered packages should be run with this change label Aug 4, 2026
@Keno
Keno merged commit 35b7e12 into JuliaLang:master Aug 5, 2026
7 of 10 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.

2 participants