Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions crates/perry-runtime/src/promise/microtasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,16 +218,28 @@ pub extern "C" fn js_promise_run_microtasks() -> i32 {
} else {
None
};
crate::async_hooks::before((*promise).async_id, (*promise).trigger_async_id);
// #1663: capture async_id + trigger as plain values BEFORE
// the callback. They are immutable for the promise's life,
// and the callback can re-entrantly drain microtasks (which
// can move the promise via GC or realloc the GC-root handle
// stack). Reading `(*promise).async_id` AFTER the callback
// to feed `after()` was the exact deref that segfaulted; use
// the captured value so `after()` needs no live promise.
let async_id = (*promise).async_id;
let trigger_async_id = (*promise).trigger_async_id;
crate::async_hooks::before(async_id, trigger_async_id);
let result = crate::closure::js_closure_call1(callback, value);
// Keep the callback result rooted across `after()` (which
// can run JS when async_hooks are active) via the value
// cell, then reload promise/next from our handles — never
// the TLS cells, which a re-entrant drain may have nulled.
// The reload goes through the out-of-line `get_raw_mut_ptr`
// (#1663) so it re-resolves the handle stack after the
// callback instead of reading a stale cached slot address.
CURRENT_MICROTASK_VALUE.with(|c| c.set(result));
let promise = promise_handle.get_raw_mut_ptr::<Promise>();
let next = next_handle.get_raw_mut_ptr::<Promise>();
crate::async_hooks::after((*promise).async_id);
crate::async_hooks::after(async_id);
if let Some(t) = t1 {
MT_TIME_NS_CALLBACK
.fetch_add(t.elapsed().as_nanos() as u64, Ordering::Relaxed);
Expand Down
16 changes: 16 additions & 0 deletions crates/perry/src/commands/compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,22 @@ pub fn run_with_parse_cache(
// bleed into this build's auto-link decisions.
let _ = perry_codegen::ext_registry::take_used_providers();

// #1663: make `--debug-symbols` retain a symbol table on every native
// target, not just emit a PDB on Windows. Previously the flag was a no-op
// on Linux/macOS, so a SIGSEGV in a compiled service (e.g. the Fastify +
// @perryts/mysql crash reported in #1663) symbolized to an unreadable wall
// of `??`, making runtime crashes nearly impossible to report. The
// canonical knob for "keep symbols" is the PERRY_DEBUG_SYMBOLS env var,
// which the codegen (`-g`/DWARF), the object-cache key, and the final
// `strip` step already all honor. Promote the flag to that env var here —
// single-threaded, before module codegen spawns rayon workers — so every
// layer observes it uniformly. Only set (never unset): the flag is an
// explicit opt-in, and a `perry dev` session that asked for symbols once
// wants them for the rest of the session.
if args.debug_symbols && std::env::var_os("PERRY_DEBUG_SYMBOLS").is_none() {
std::env::set_var("PERRY_DEBUG_SYMBOLS", "1");
}

match format {
OutputFormat::Text => println!("Collecting modules..."),
OutputFormat::Json => {}
Expand Down
4 changes: 3 additions & 1 deletion crates/perry/src/commands/compile/post_link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ use super::{CompilationContext, ObjectCache};
/// compilation target whose host `strip` can't parse foreign object
/// formats (iOS/visionOS/tvOS/watchOS/HarmonyOS/Android), and when
/// `PERRY_DEBUG_SYMBOLS=1` is set so crash backtraces stay
/// symbolicated.
/// symbolicated. `--debug-symbols` (#1663) promotes itself to that env
/// var in the compile driver, so passing the flag also takes this skip
/// path on Linux/macOS.
///
/// When `ctx.needs_plugins` is true the build uses `strip -x` to
/// retain exported symbols — `dlopen`'d plugins resolve
Expand Down
8 changes: 7 additions & 1 deletion crates/perry/src/commands/compile/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,13 @@ pub struct CompileArgs {
/// otherwise an unreadable wall of `<unknown>`. Also skips `/OPT:ICF`
/// (identical-COMDAT folding) so distinct functions don't collapse
/// to one symbol in the backtrace. Larger binary; off by default.
/// (Non-Windows targets: reserved — no behavior change today.)
///
/// On Linux/macOS (#1663) this now skips the final `strip` and emits
/// `-g` DWARF, so a SIGSEGV in a compiled service backtraces to real
/// `js_*`/user function names + `file:line` under lldb/gdb instead of
/// `??`. Implemented by promoting the flag to the `PERRY_DEBUG_SYMBOLS`
/// env var in the compile driver, which codegen, the object-cache key,
/// and the strip step already honor.
#[arg(long)]
pub debug_symbols: bool,

Expand Down
46 changes: 46 additions & 0 deletions test-files/test_issue_1663_deep_async_reentry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Regression test for #1663: SIGSEGV in js_promise_run_microtasks during
// async resumption — the deeper, Fastify-handler-shaped variant.
//
// This widens the original test_issue_1663_async_reentry_microtask.ts case:
// a prior COMPLETED awaiting-call (like a Fastify onRequest hook), followed
// by a non-transformed async closure (like a route handler passed to
// app.post) that performs THREE sequential awaiting-calls (the read-body →
// SELECT → INSERT chain). Each `await` re-entrantly drains the microtask
// queue; before #1675 the Task::Promise arm reloaded the running promise
// from a clobbered TLS cell and dereferenced `(*promise).async_id` (offset
// 0x30) on a NULL pointer. This shape exercises a deeper re-entrant nesting
// than the original repro.
//
// Expected output (byte-identical to `node --experimental-strip-types`):
// prior: ok
// handler: 6
// reached

async function awaitingCall(n: number): Promise<number> {
await Promise.resolve();
return n;
}

async function runCb(cb: () => Promise<void>) {
await cb();
}

async function priorCompleted() {
await Promise.resolve();
console.log("prior: ok");
}

async function handler() {
await runCb(async () => {
const a = await awaitingCall(1);
const b = await awaitingCall(2);
const c = await awaitingCall(3);
console.log("handler:", a + b + c);
});
}

(async () => {
await runCb(priorCompleted);
await handler();
console.log("reached");
})();