Skip to content

resources: host-initiated dtors run as full canonical lifts - #163

Merged
lannbot merged 1 commit into
mainfrom
fix/dtor-lift-normalization
Aug 20, 2026
Merged

resources: host-initiated dtors run as full canonical lifts#163
lannbot merged 1 commit into
mainfrom
fix/dtor-lift-normalization

Conversation

@lannbot

@lannbot lannbot commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

resources: host-initiated dtors run as full canonical lifts

Fixes #160. Design settled by the reference itself: definitions.py canon_resource_drop (line 2319) invokes the dtor through Store.lift with CanonicalOptions(async_ = False)a full canonical lift. The held-bracket path this PR deletes was the divergence, not the fix candidate.

The bug (#160)

The host-initiated drop path (callDtorGated(allowAsync=true), the #85 shape) called the dtor bare and HELD enterFrom(null) — leaf and synthetic root — across the returned promise, with no Task/Thread behind the activation. Three structural defects:

The fix

  • createDtorEntry (exec/boundary.ts): the dtor's entry built with the existing createLiftedFunctionft = [u32] → (), inert ResolvedOptions, coreType (i32) → (), per-mode enterWasm inside the harness. The activation gets a real Task/implicit Thread; the entry bracket releases at the first park (leave() before the drive); suspension points resume through tick; settled tails flow through scheduler: resumeWith has the same not-host-enterable window as tick, and the tick fix cannot transfer #156's serviceSettled; traps poison through the harness's own poison().
  • hostDtorCall (exec/boundary.ts): the host-initiated drop entry — impl === null direct path unchanged; otherwise the lifted entry (executor pre-wires ResourceTypeInfo.dtorHost; direct-construction tokens get it lazily). Async failures park on store.hostFailure ??= (the existing channel). The completion promise is deliberately not registered in pendingHostCalls: that registration was the "external work" lie, and it had no consumer beyond the drivers it misled. The dtor's genuine external dependencies — its host imports — register themselves when they park.
  • callDtorGated (cabi/handles.ts) keeps only the guest-initiated synchronous discipline (allowAsync removed); the resources: own-drop dtor bypasses may_enter gating and trap-poisoning; JS-initiated drops skip the promising entry #85 guest-side frame-rule limitation is untouched.
  • One additive harness parameter, allowAsyncCompletion: a sync-typed lift whose caller needs no synchronous answer (only the dtor today — drop(): void is documented non-blocking) skips driveSyncLift, so a plain-mode thenable-returning (host JS) dtor parks instead of tripping the sync loop's bogus deadlock. Not a weakening: drive enforces the same deadlock trap asynchronously — the substitution jspi mode already makes unconditionally.
  • Executor comment rewritten: the old "promising would leave the impl entered for a turn after every drop" hazard is gone by construction (bracket released before the hop; the hop-quiescence entry gate covers drop-then-call).

Pinned behavior changes (the #85 pins are revised — that behavior was the bug)

  • "holds the gate until it settles" → does not hold the gate: impl.mayEnter === true while the dtor is in flight; no pendingHostCalls completion entry.
  • New scheduler: the held async-dtor bracket can strand the dtor's own suspension points — a pendingHostCalls entry whose settlement needs the scheduler #160 regression pin: a dtor parked on a scheduler-resumable suspension point (under the harness, currentTask() resolves to the dtor's own task) completes once ready — on the old code this test dies at PendingCapability before the park, the loud presentation of the missing-task defect.
  • New sibling pin: sibling instances stay mayEnterFrom(null) while a dtor is in flight (pre-fix: locked through the root).
  • Rejected-dtor semantics preserved: store.hostFailure === boom, impl poisoned (mapCoreException passes non-RuntimeError rejections through unchanged).

Relation to #156/#161: this removes the only macro-time reachability window of the deferral class; the deferral itself remains correct and necessary for the synchronous windows.

Gates

just gates — full pass. Conformance 1416 commands: 1257 passed, 0 failed, no expectation changes; sched-seeds clean. Embedder surface unchanged (drop(): void still non-blocking, same failure channel), so the lockstep version is left alone per AGENTS.md.

Fixes #160. definitions.py canon_resource_drop (line 2319) invokes the
dtor through Store.lift with CanonicalOptions(async_ = False) — a full
canonical lift. The host-initiated drop path instead called the dtor
bare while HOLDING enterFrom(null) across its returned promise
(callDtorGated allowAsync, the #85 shape), with no Task/Thread behind
the activation. Three structural defects followed:

  * #160: the held bracket made the impl non-host-enterable, so tick's
    enterability filter (#155) could never resume a suspension point of
    the dtor's own activation, while the completion promise sat in
    pendingHostCalls advertising external work — every driver parked
    on it forever. (Between calls the empty ambient made currentTask()
    signal PendingCapability before the park — the loud presentation;
    the silent wedge needed a foreign ambient, the #24 class.)
  * it was the runtime's only enterFrom(null) bracket spanning an
    await: the macro-time window of the #156 class, locking every
    sibling instance through the synthetic root for the dtor's whole
    activation.
  * built-ins inside the dtor had no ambient task at all.

Now: createDtorEntry builds the dtor's entry with createLiftedFunction
(ft = [u32] -> (), inert options, coreType (i32)->()), and hostDtorCall
routes every host-initiated drop through it (executor pre-wires
dtorHost; direct-construction tokens get it lazily). The activation has
a real Task/implicit Thread, the bracket releases at the first park,
suspension points resume through tick, settled tails flow through
serviceSettled, and traps poison through the lift harness's own path.
Async failures park on store.hostFailure (first failure wins); the
completion promise is deliberately NOT a pendingHostCalls entry — that
registration was the "external work" lie, and it had no other consumer.
callDtorGated keeps only the guest-initiated synchronous discipline.

One additive harness parameter, allowAsyncCompletion: a sync-typed lift
whose caller needs no synchronous answer skips driveSyncLift so a
plain-mode thenable-returning (host JS) dtor parks instead of tripping
the sync loop's bogus deadlock; the real deadlock trap is still
enforced by drive, the same substitution jspi mode makes.

Revises the #85 pins (the gate-hold behavior WAS the bug) and adds
regression pins: a dtor parked on a scheduler-resumable suspension
point completes, and siblings stay enterable while a dtor is in
flight.

Gates: just gates (full pass; conformance 1257/0, no expectation
changes; sched-seeds clean). Embedder surface unchanged (drop() still
non-blocking, same failure channel); lockstep version untouched.
@lannbot
lannbot enabled auto-merge August 20, 2026 19:34
@lannbot
lannbot merged commit 7eb6c46 into main Aug 20, 2026
4 checks passed
@lannbot
lannbot deleted the fix/dtor-lift-normalization branch August 23, 2026 16:51
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.

scheduler: the held async-dtor bracket can strand the dtor's own suspension points — a pendingHostCalls entry whose settlement needs the scheduler

2 participants