← Back to AILP Home

Drop interaction

Drop (the v0.29.x opt-in RAII layer documented in guide/drop/) fires correctly in async function bodies in v0.31.x. This page records what was verified in v0.31.0.5 (D-10) and what is still deferred.

What works (verified in v0.31.0.5)

(a) Drop on async completion

When an async func: body reaches its closing } via fall-through, pass v, or return Result{...}, every Drop in scope fires before the coroutine resolves the promise.

use "drop.npk".*;

struct DropMe { int32:tag; };
impl:Drop:for:DropMe = {
    func:drop = NIL($$m DropMe:self) {
        println("DROP");
    };
};

async func:work = int32() {
    wild DropMe:d = DropMe{ tag: 1i32 };
    pass 0i32;        // Drop of d fires BEFORE the promise resolves
};

Regression bug351: the DROP marker appears in stdout before run_tests() completes and main's exit 0 runs.

(c) Drop on fail unwind

fail e inside an async body also runs Drops before storing the error Result<T> to the promise:

async func:falling = int32() {
    wild DropMe:d = DropMe{ tag: 2i32 };
    fail 7i32;        // Drop of d fires, THEN the future resolves to is_error=true
};

Regression bug352: the DROP marker appears in stdout, and the awaiter sees is_error == true afterwards. The async body "unwinds" through the Drops the same way a sync function does.

LIFO ordering across nested scopes

Drops walk innermost-out, reverse declaration order — same as sync. The inner scope's Drops run at the inner } before the outer scope's Drops run at the outer }:

async func:nested = int32() {
    wild DropMe:a = DropMe{ tag: 1i32 };
    {
        wild DropMe:b = DropMe{ tag: 2i32 };
        // inner `}` here → drop(b) fires first
    }
    // outer `}` here → drop(a) fires
    pass 0i32;
};

Regression bug353: stdout shows DROP-TwoB before DROP-TwoA even though a was declared first. The "TwoB / TwoA" naming in the fixture is for marker ordering — it confirms the inner-scope Drop fires at the inner }, not deferred to the outer }.

What is not yet verified: (b) shutdown of a suspended task

The third Drop case from the audit (D-10 (b)) — "what happens to Drops when the executor shuts down with tasks still suspended mid-await" — is not reachable from the user surface in v0.31.x.

The reason: no user-surface awaitable produces real machine- level suspension. Every await over an async func: call resolves synchronously (because the awaited coroutine itself runs to completion in the same npk_executor_run drain before the awaiter resumes). And await does not accept the runtime's NitpickFutureHandle — the only thing that would really suspend.

Until a user-surface real-suspend primitive lands (either an awaiter bridge to NitpickFutureHandle, or an explicit yield_now() / sleep primitive), this case cannot be tested through .npk fixtures. The runtime side also has a known gap: Executor's destructor does not call __nitpick_coro_destroy on unfinished tasks, so suspended-task Drops would currently leak even if the test could be written.

Deferred to a later cycle (Phase 2 network, Phase 1 awaiter bridge slice, or whichever lands first).

exit N still skips Drop

This is unchanged from sync:

async func:bad = int32() {
    wild DropMe:d = DropMe{ tag: 1i32 };
    exit 1;          // hard process exit — d.drop() does NOT run
};

DROP-DEC-008 (exit skips Drops) applies inside async func: the same way it applies inside func:. If you need destructors to run, use pass / fail and let the awaiter handle the result.

The compiler-injected npk_executor_run(NULL) runs before main's explicit exit N. Any async task that completes during that drain will run its Drops. Any task still suspended at the start of exit is abandoned (the (b) case above).

Move semantics across pass

The DROP-DEC-004 bare-identifier move applies in async bodies the same way:

async func:hand_off = int32() {
    wild DropMe:d = DropMe{ tag: 1i32 };
    pass d.tag;       // d.tag is a field expr, not a bare ident → d is dropped
};

async func:hand_off_bare = wild DropMe() {
    wild DropMe:d = DropMe{ tag: 1i32 };
    pass d;           // bare ident → ownership moves to caller, Drop skipped on d
};

The async lowering does not change the move rules. The Result<T> that gets stored to the promise wraps the moved-out value the same way it wraps a regular pass return.

Drops vs defer

Defers fire after Drops, same as sync (DROP-DEC-010). The suspension-point question — "do defer blocks survive an await?" — has the same answer as the borrow question: the defer itself survives (it's part of the coroutine frame), but any borrows it captures must follow the ARIA-041 rule. See borrow.md.

See also