← Back to AILP Home

FAQ

Recurring questions about the Phase 1 async surface, with answers anchored to what actually lands in v0.31.x.

"Why doesn't await work over npk_read_file_async?"

Because there is no bridge yet. The two future types (coroutine future from async func: calls vs runtime NitpickFutureHandle from npk_*_async calls) live in different worlds. The coroutine future is owned by the LLVM coroutine machinery and surfaces through await. The runtime NitpickFutureHandle lives on a separate 4-thread I/O pool and surfaces through the C-API (npk_future_is_ready / is_error / get_value / destroy). Bridging the two requires a custom awaiter that turns "runtime future ready" into "resume this coroutine" — a real slice of engineering work that is explicitly deferred out of v0.31.x.

The interim is the spin-poll idiom documented in file_io.md.

"Is the executor really single-threaded?"

Yes. executor.cpp is a run-to-completion single-threaded executor. work_stealing.cpp (318 lines) is compiled into the runtime but is not the default and is not wired into npk_get_global_executor. The v0.31.0.0 D-1 decision was to ship the single-threaded executor for the whole v0.31.x cycle and defer the multi-threaded one.

The file I/O 4-thread pool is separate from the executor. It is a pool of I/O workers that resolve runtime futures; user- level coroutines never run on those threads.

"Why was catch removed?"

Two reasons:

  1. Consistency with the v0.21.1 A-014 decision. v0.21.1 already rejected catch and pointed users at the Result<T> family (defaults / ? / !! / pick). The v0.31 PLAN's proposal to bring catch back as an await-site error handler would have created a second, parallel error-handling story.
  2. Audit recommendation. The v0.31.0.0 audit (D-8) called Option A — pull catch from the language surface entirely — and the user ratified it. v0.31.0.2 shipped the pull.

catch is now a plain identifier; try { ... } catch { ... } still fails to parse, but via a generic try block error, not via a catch-specific diagnostic. See errors.md.

"Why does the borrow checker reject $$i across await?"

Conservative simplicity. A relaxed rule ("immutable borrows survive if no other task can mutate the source") would need interprocedural reasoning about the rest of the program's async tasks. The per-function borrow checker is not set up for that. The v0.31.0.4 / D-5 decision was to invalidate both flavours unconditionally; relaxation is a future-cycle optimisation, not a correctness gap.

See borrow.md.

"Does the GC follow pointers into suspended coroutine frames?"

Yes, since v0.28.6.1, and v0.31.0.6 fixed a real bug there. gc_integration.cpp::GCCoroAllocator::scan_frame_slots exposes each coroutine-frame slot (not just the dereferenced value) so the minor-GC mark phase can rewrite the slot after evacuating the object it points at. Without that fix, slots in suspended frames were left dangling at vacated nursery payload (whose first word is the forwarding pointer → reads garbage on resume).

The fix is verified by a C++-level harness (test_async_gc_suspended_frames_v03106) with 16 suspended frames + a 4000-alloc storm forcing 4 minor GCs. No .npk fixture exists because the user surface has no real-suspend primitive yet.

"Does pin survive an await?"

Yes. Pinned objects keep their address across coroutine suspension. Verified in v0.31.0.6 / D-11 by test_async_pin_across_suspend_v03106 (8 suspended frames, each pins+registers an object, 4000-alloc storm; pinned addresses unchanged after 2 minor GCs).

That test is single-threaded by design — the GCCoroAllocator::frame_metadata is an unprotected std::map, so true multi-thread concurrent suspended-frame pinning would need a mutex retrofit. Deferred.

"Why does the compiler inject npk_executor_run(NULL)?"

So that programs using async actually drain their work before exit N. Without the injection, a program that did drop run_tests(); exit 0; would spawn the task and immediately exit before the executor ever ran it. The injection (v0.31.0.1 / D-2) makes async-containing modules "just work" in a main that doesn't know about the executor.

npk_executor_run(NULL) is a no-op when no global executor exists (no async tasks were ever spawned), so sync-only modules pay nothing. Regression bug336 checks this — a sync-only main has zero references to npk_executor_run in the emitted IR.

"Can I make main itself async?"

Not in Phase 1. main must be func:main = int32() { ... };. The conventional way to launch async work from main is drop <call>:

func:main = int32() {
    drop run_tests();    // launches the async coroutine
    exit 0;              // executor drain runs first, then exit
};

The drain (injected npk_executor_run(NULL)) gives the task a chance to complete before exit aborts.

"What about try / try! / try??"

try is not a Nitpick keyword. The error-handling syntax is Result<T> + defaults / ? / !! / pick. See errors.md for the substitute table.

"What about timers, sleep, yield?"

Deferred. The runtime has the plumbing (the executor can suspend and resume); the user-surface spelling for "yield to the executor without depending on an I/O operation" is not in Phase 1. The closest you can get today is a drop <call> that the executor will schedule after the current task suspends — but since await over async func: calls resolves synchronously, there is no observable interleaving.

"Is network async usable from .npk?"

No. async_net.cpp (705 lines, socket / connect / accept / recv / send) is compiled into the runtime but has no extern bindings exposed at the user surface in Phase 1. Phase 2 will add the bindings and (probably) the awaiter bridge so the network surface can use await directly.

"What happens to a suspended task at process exit?"

Today: the task is abandoned. Its frame is leaked, its Drops do not fire (D-10 (b) deferred), and the executor's destructor does not call __nitpick_coro_destroy on unfinished tasks.

This case is not reachable from the user surface in v0.31.x because no user-surface awaitable produces real machine-level suspension — every await over async func: resolves synchronously. Once the awaiter bridge lands and real suspension becomes user-observable, this becomes a real shutdown story that will need a slice.

"Can I declare Future<int32>:f = ...?"

No. Future<T> is not nameable in Nitpick user code in Phase 1. The internal coroutine future is implementation detail; the runtime NitpickFutureHandle surfaces as wild int8* (extern) / wild int8-> (body). A nameable Future<T> is a future-cycle item, not Phase 1.

"Where is the network / sleep / timer / select / cancel?"

Phase 2, Phase 2, Phase 2, Phase 2+, and "TBD" respectively. Phase 1's job is to lock down the core await/borrow/Drop contract and ratify the executor model so later phases can build on top without re-litigating decisions.

See also