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:
- Consistency with the v0.21.1 A-014 decision. v0.21.1
already rejected
catchand pointed users at theResult<T>family (defaults/?/!!/pick). The v0.31 PLAN's proposal to bringcatchback as an await-site error handler would have created a second, parallel error-handling story. - Audit recommendation. The v0.31.0.0 audit (D-8) called
Option A — pull
catchfrom 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
README— chapter index and validation snapshot.surface.md— declaration / await / executor drain.errors.md— pass / fail / ARIA-040 / no catch.borrow.md— ARIA-041 borrow-across-await.drop.md— Drop on completion + fail unwind.file_io.md— the spin-poll surface.META/NITPICK/ROADMAP/0.31/AUDIT_v0.31.0.0.md— the audit and ratified decisions D-1..D-11.