Skip to content

Native shop-admin signup SIGBUS in toUser → dtRequiredToIso (downstream of @perryts/mysql MyDateTime.toDate corruption) #859

Closed

Description

@proggeramlug

Symptom

Native-compiled skelpo-shop-admin (./build/server) at perry 0.5.914 crashes with SIGBUS / exit 138 during POST /v1/auth/signup. The first INSERT INTO users commits (visible in MySQL), then the process dies — curl gets Empty reply from server and the connection drops. tsx server/main.ts against the same source + same DB returns the correct JSON / HTTP 201.

This is the v0.5.914 surfacing of what at v0.5.912 was a silent body-0/HTTP 200 — same site, harder failure now that the Invalid-Date sentinel is in.

Pinned crash site (checkpoint-instrumented trace)

Instrumented server/persistence/users.ts::createUser/getUserById/toUser:

U1 INSERT                      ← createUser INSERT runs
U2 done, pre getUserById       ← INSERT resolved, row visible
G1 pre queryOne                ← getUserById entered
G2 queryOne obj                ← SELECT returned the row
T0 enter   (toUser entered)
T1 id=019e2fac-...             ← bufToId(r.id) works
T2 emailVerified=null          ← dtToIso(null) works
T3 totp=null                    ← dtToIso(null) works
                                ← SIGBUS. No T4.

Next statement is dtRequiredToIso(r.createdAt), which calls r.createdAt.toDate().getTime() on the MyDateTime object the @perryts/mysql driver decoded. That .toDate() call is where the process dies.

Root cause

The proximate trigger is #788 — closure-captured numeric params read as 0 inside object-literal : Date method (filed separately). MyDateTime.toDate() is exactly that shape, so for every decoded DATETIME the captured y/mo/d/... read as 0 inside the method and new Date(Date.UTC(0, ...)) produces a year-0 Date (-62155492224598 ms). When that value flows into dtRequiredToIso → d.toISOString() and the resulting string into perry's JSON / fastify reply path, something in the formatting / serialization chain assumes a finite, plausible timestamp and segfaults.

In a standalone repro of just the MyDateTime.toDate().toISOString() shape (no Fastify, no mysql connection — see #788) the same code returns a garbage 0000-05-15T17:29:36.598Z string without crashing. The shop-admin path additionally goes through fastify's JSON reply, the post-#748 wait_for_promise condvar path, and likely the @perryts/mysql worker-thread → main-thread Date hand-off. One of those layers can't survive a year-0 / Invalid Date and faults.

Repro

Local: clean checkout of skelpo-shop-admin, cargo build --release of perry at HEAD (e18cdb07 / v0.5.914), then:

mysql -uroot -e "DROP DATABASE IF EXISTS shopadmin; CREATE DATABASE shopadmin"
KEK_BASE64=... JWT_SIGNING_KEY_PEM=... DB_DSN='mysql://root:@127.0.0.1:3306/shopadmin' \
  BIND_ADDR='127.0.0.1:18080' ./build/server &
curl -X POST http://127.0.0.1:18080/v1/auth/signup \
     -H 'content-type: application/json' \
     -d '{"email":"x@y.com","password":"correct-horse-battery-staple","accountName":"X"}'
# → curl: (52) Empty reply from server; server process exits 138.

tsx server/main.ts against the same DB+env returns the correct {accessToken, refreshToken, expiresAt, user, account} payload with HTTP 201 and all five tables populated.

Tracking history

This is the same end-user failure that drove #665 and #748:

Suggested fix scope

  1. Fix AsyncLocalStorage: real async-context tracking across await / microtasks / timers #788 (the captures) — that should be enough for the user's signup to work end-to-end. The corrupt-Date path stops producing year-0 values and toISOString returns the real timestamp.
  2. Independently, find the downstream layer that segfaults on a year-0/Invalid Date and harden it — same value at the same call site shouldn't take down the process. (My standalone repro of the same shape returns a garbage string without crashing, so the segfault is specific to one of: fastify reply path, @perryts/mysql worker→main thread Date crossing, or wait_for_promise resuming with a Date argument it doesn't expect.)

Perry version at repro: 0.5.914.

Activity

  1. proggeramlug commented on May 16, 2026

    @proggeramlug
    ContributorAuthor

    Reopening — the #858 fix landed correctly (verified at v0.5.922; all my prior standalone Date repros pass byte-identical to tsx, and MyDateTime.toDate() now returns valid Dates with valid millis). That removed the proximate trigger I'd named, but the shop-admin signup still SIGBUSes on POST /v1/auth/signup — same exit-138 symptom as before, different position.

    What changed (post-#858)

    With heavy persistence-layer instrumentation (U/G/T/S checkpoints in users.ts, pool.ts, auth.ts), the trace runs much further than before:

    S7 pre createUser
      U1 INSERT                ← createUser INSERT runs
      U2 done
      G1                       ← getUserById entered
      G2 row=obj               ← SELECT returned the row
      T0                       ← toUser entered
      T1 id=019e316f-…         ← bufToId(r.id) works
      T2 ev=null
      T3 te=null
      T4 ca=2026-05-16T17:37:08.153Z   ← dtRequiredToIso(createdAt) — works now! (was the #858 site)
      T5 ua=2026-05-16T17:37:08.153Z
      T6 done                  ← toUser built ok
      G3 toUser done
      U3 getUserById row
    S8 createUser done id=019e316f-… created=2026-05-16T17:37:08.153Z
    S9 pre createAccount
                                ← SIGBUS. No CA1, no further checkpoints.
    

    The #858 fix made the original pinned site (dtRequiredToIso(r.createdAt) inside toUser) work end-to-end. Crash has moved to between S9 pre createAccount and the createAccount function body entering — i.e. inside the expression await createAccount({ name: …, trialEndsAt: trialEndsAt() }).

    And it's instrumentation-sensitive

    Same source, same perry 0.5.922 build, only the volume of console.error calls differs:

    Instrumentation Last checkpoint reached
    Heavy (~15 checkpoints in users/pool/auth) S9 pre createAccount (dies inside await createAccount(...))
    Minimal (3 console.error in auth route) ZZ3 pre-createUser (dies inside await createUser(...))

    That's the diagnostic — the SIGBUS position is not a fixed codegen site; it shifts with allocation/GC pressure or microtask-queue depth. Same code, same DB, same env, just fewer/more console.error calls on the path before the failure point. Strongly suggests a memory-pressure / GC-during-await / async-chain-state interaction rather than a single mis-compiled statement.

    Concrete repro

    Local: clean checkout of skelpo-shop-admin, perry at HEAD (v0.5.922):

    mysql -uroot -e "DROP DATABASE IF EXISTS shopadmin; CREATE DATABASE shopadmin"
    KEK_BASE64=… JWT_SIGNING_KEY_PEM=… DB_DSN='mysql://root:@127.0.0.1:3306/shopadmin' \
      BIND_ADDR='127.0.0.1:18080' ./build/server &
    curl -X POST http://127.0.0.1:18080/v1/auth/signup \
         -H 'content-type: application/json' \
         -d '{"email":"x@y.com","password":"correct-horse-battery-staple","accountName":"X"}'
    # → curl: (52) Empty reply from server; server exit 138; only `users` row written.
    

    tsx server/main.ts against the same DB+env still returns the full {accessToken, refreshToken, expiresAt, user, account} JSON with HTTP 201 and all five tables populated.

    What I'd suggest next

    The position-shifts-with-instrumentation behavior is the most actionable clue. Suspects, in order:

    1. A native-promise resumption (rate-limiter / argon2 / @perryts/mysql) re-enters the async-handler frame and a register or stack slot holding a downstream-await target is clobbered. The variable instrumentation moves things around enough to push the clobber to a different site.
    2. GC triggering across a long await chain in a single request — the captured state of the outer async function (route handler) isn't fully rooted. users.ts/pool.ts allocate strings (UUID hex, MySQL date format strings, JSON bodies) — console.error calls also allocate. Different allocation count → GC fires at a different point → different live promise/captured slot freed.
    3. A perry-runtime per-request resource (an event-pump tick counter, microtask-queue index, GC barrier counter) overflowing/wrapping after N native promise resolutions.

    For (1) I'd capture the SIGBUS via RUST_BACKTRACE=full + lldb on the compiled ./build/server to get the faulting PC and the LLVM-IR __init symbol it's in. I tried RUST_BACKTRACE=full already and nothing was emitted before the process died — likely needs a SIGBUS handler installed in perry-runtime (or a wrapper script catching the crash and running lldb -p on the core). Happy to do that if you can point at a recipe perry uses internally for this kind of in-the-field crash capture.

    Perry version at repro: 0.5.922 (HEAD 4edcb077). Reopening since the symptom persists and the position-shift makes it look like a runtime/codegen interaction rather than a single missing fix; the original #859 title is no longer accurate (toUser works fine), so a rename to something like "native shop-admin signup SIGBUS during multi-await Fastify handler; crash site shifts with instrumentation volume" would track it better.

  2. proggeramlug commented on May 16, 2026

    @proggeramlug
    ContributorAuthor

    v0.5.923 with e9186c7c (pin native-bridged Promises while in flight on tokio worker) is partial progress — but signup still SIGSEGVs / SIGBUSes deterministically, just at a moving site.

    Concrete evidence: the fix moved the needle but didn't close it

    Two full-rebuild runs at perry 0.5.923, same shop-admin source, same DB, same env. Only the volume/placement of console.error checkpoints differs.

    Run A — heavy checkpoints in users.ts (T0–T6 inside toUser) + auth.ts (S7–S15):

    S7 …
    U1, U2, G1, G2 obj, T0…T6, G3, U3, S8, S9
                                      ← SIGSEGV (exit 139). No S10.
    

    DB after Run A:

    u | a | s | al | m
    1 | 1 | 0 |  0 | 0
    

    accounts now has 1 row — pre-fix it was always 0. So createAccount's INSERT promise is surviving long enough to commit. Crash is post-INSERT, before the route handler's await createAccount(...) resumes (no S10).

    Run B — heavy checkpoints in accounts.ts (CA1–CA6, TA0–TA5) + same S7/S8/S9/S10 in auth.ts, minus T* in toUser:

    S7
    U1, U2
                                      ← SIGSEGV. No U3, no G1, no T*, no CA*.
    

    DB:

    u | a | s | al | m
    1 | 0 | 0 |  0 | 0
    

    Identical perry, identical user code (the only diff is which functions I added console.error calls to). Crash position jumps from "inside createAccount await-resume" all the way back to "inside createUser's await getUserById". accounts=1 vs accounts=0 is the clean tell that the work did execute in Run A, just unwound badly.

    What this means for the fix

    e9186c7c correctly pins promises at spawn_for_promise / spawn_for_promise_deferred / the third entry point — and the heavy-instrumentation run proves the pinning is doing real work (the createAccount INSERT promise survives to commit, where pre-fix it didn't). But there's clearly at least one more code path where a Promise's pointer crosses to a worker without being rooted; instrumentation that perturbs allocation timing changes which path's window the GC happens to land in.

    Candidates worth checking, based on the trace + the fix's own framing:

    1. getXById's SELECT promise after the INSERT in createUser / createAccount — Run B dies on await getUserById(id, conn) inside createUser's body (U2 prints, U3 doesn't). That promise also goes main → @perryts/mysql worker → main; check whether its allocation site goes through one of the three patched entry points or a fourth.
    2. The codegen-emitted async-function result promise — every async function f() {...} allocates P = js_promise_new() to return; if f then awaits a native promise on a worker, P is the "outer" promise that sits in the parent's await loop. Its lifetime crosses worker boundaries indirectly. Pinning the bridged promise alone doesn't necessarily root the awaiting outer promise.
    3. Microtask continuations queued from worker resolutions — when the worker resolves the bridged promise and that enqueues a .then(continuation) microtask, the continuation closure captures the outer async state machine; if that capture's holder is an unrooted slot, GC can still sweep it before the microtask runs.

    The position-sensitivity to console.error count is the diagnostic — it says the freed-pointer window is small and adjacent allocations decide whether the swept page has been reused/unmapped yet.

    Repro

    Local: clean shop-admin checkout, perry HEAD (v0.5.923, full cargo build --release), MySQL up with empty shopadmin DB. Single POST /v1/auth/signup → curl: (52) Empty reply from server, server exits 138/139 (varies). My instrumentation diffs are in /tmp/users.bak9 / /tmp/accounts.bakB etc. on this machine if useful — happy to commit them as a test-files/test_issue_859_signup_chain.ts shape if you want a deterministic-enough in-tree repro (the catch being it needs a live MySQL on 127.0.0.1:3306, which I think is why the prior agent could only do the synthetic Promise-tuple repro).

    For the next pass, MallocStackLogging=1 on macOS + lldb -p once it crashes would show the freed-then-reused promise allocation's last js_promise_new callsite — that should point at the unrooted entry path #4. Happy to drive that locally if you can sketch the right perry-side hook.

    Reopening; suggested rename: "native shop-admin signup still SIGSEGVs after #873 — additional Promise→worker entry path not pinned (position varies with instrumentation)".

  3. proggeramlug commented on May 17, 2026

    @proggeramlug
    ContributorAuthor

    New diagnostic data at v0.5.934 — the crash is not GC-related. The pinning fix in e9186c7c is doing real work (more code paths complete than before) but it's chasing the wrong root cause for the residual signup crash.

    (1) Captured an lldb backtrace

    lldb -batch ./build/server, triggered signup with one curl, caught the fault:

    * thread #1, queue = 'com.apple.main-thread',
      stop reason = EXC_BAD_ACCESS (code=1, address=0x726f727249)
        frame #0: server`<stripped> + 2212    ldr w26, [x19, #0x4]
        frame #1: server`<stripped> + 436
        frame #2: server`<stripped> + 624
        frame #3: server`<stripped> + 1664
        frame #4: server`<stripped> + 1056
        frame #5: server`<stripped> + 828
        frame #6: dyld`start
    

    The faulting instruction is ldr w26, [x19, #0x4] — a 4-byte load at offset 4 of whatever's in x19. x19 holds 0x726f727249. That's ASCII bytes 49 72 72 6F 72 = "Irror" (little-endian as the CPU read them off memory). A 5-byte string fragment, not a heap pointer. A real Promise/object pointer on macOS arm64 would be in the 0x000000010xxxxxxx (binary) or 0x000000014xxxxxxx (mmap'd heap) range — 0x726f727249 is neither.

    This is type confusion — somewhere a value that is a string (or contains string bytes in low-order positions) is being treated as an object pointer and [ptr + 0x4] is dereferenced. Not a use-after-free where a real but freed pointer is dereferenced — those would fault at an address that looks like a heap address, possibly returning garbage instead of crashing on a clearly-invalid one.

    (2) PERRY_GC_DIAG=1 — zero GC events during signup

    Ran with PERRY_GC_DIAG=1; the failing signup produces zero GC-related lines between server-up and crash. So the pinning code path the fix protects never even fires during the failing window — GC isn't running here.

    (3) PERRY_GC_DISABLE=1 bisect — same crash

    To rule it out conclusively I patched a PERRY_GC_DISABLE=1 knob into gc_collect_inner() (no-op every collection, leak everything), rebuilt perry-runtime, ran signup. Same SIGSEGV exit 139, same address shape, same users=1 accounts=0 DB signature as without the knob. (Knob reverted; checkout clean.)

    So: GC fully disabled → same crash → the residual #859 bug is not a GC-rooting issue. The pinning fix in e9186c7c is correct for the usize-as-Promise-pointer-across-tokio path it patches, and the heavy-instrumentation run that produces accounts=1 proves it's doing real work for some other request shape. But the signup-failure path is hitting a different bug, in a different code site, with a different root cause.

    (4) Where to look instead

    Type confusion at [x19+0x4] reading a "Irror" string fragment — the question is which code site is calling a method on a value it expects to be an object but that's actually carrying string bytes in the low 48 bits. Two hypotheses:

    a. NaN-box tag-strip mistake. A NaN-boxed string 0x7FFF_726F_7272_49xx whose top 16 bits get zeroed by a wrong mask in some unbox_to_pointer path. Hits whenever the bottom 48 bits happen to look like a not-immediately-faulting address (this one isn't real but a malformed mask could still produce an address that's deep enough into a string buffer to read past it and fault on the next page).

    b. A Result::Err(reason) from a stdlib bridge whose reason-pointer is being passed where the resolve-value pointer was expected. error / Error strings show up all over the binary (rustls error variants, mysql2 error paths, RateLimiterQueueError). If a stdlib reject path stores the error message's first 5–8 bytes into a slot that the resumer reads as *Promise, you'd get exactly this — EXC_BAD_ACCESS at a small-looking address that decodes to ASCII letters.

    The heavy-instrumentation run reaches S9 pre createAccount and accounts=1 (the createAccount INSERT committed) — so the failing await is most likely the route handler's await createAccount(...) resumption, reading the resolved Account value to bind it to const account. That's the value that gets dereferenced at [x19+4]. If createAccount's returned Account pointer got swapped with an error-string pointer somewhere on the resolve path, you'd hit this exact shape.

    (5) What the failure mode looks like for tracking

    • GC verified not involved (PERRY_GC_DIAG, PERRY_GC_DISABLE bisect).
    • Faulting address 0x726f727249 decodes to "Irror" — not a heap pointer.
    • Position varies with console.error count → register/spill layout decides which of multiple sites the bad value reaches the deref. Suggests a single bug producing a small bag of corruptible values, with the spill landing on different code paths.
    • Local checkout clean at HEAD 70868f9a / v0.5.934, perry-runtime + perry-stdlib rebuilt, all instrumentation reverted, no half-fixes.

    Happy to drive a samply / instruments capture or build perry with debug symbols if that'd help — nm build/server is fully stripped so my frame symbolication came back as __mh_execute_header + offset, which the maintainer's local symbol cache (or a cargo build without --release) would resolve immediately.

  4. proggeramlug commented on May 17, 2026

    @proggeramlug
    ContributorAuthor

    Two new artifacts that should unblock the maintainer-side investigation:

    (1) Standalone 50-line repro — no shop-admin source needed

    issue-859-repro/repro.ts in the user repo (will share separately if useful). The full ingredient list still needs to be present to reproduce — @perryts/mysql + argon2 + rate-limiter + Fastify route + the await getById(id) after await exec(INSERT) pattern with the MyDateTime.toDate() consumer — but it's now decoupled from the shop-admin codebase. Same SIGSEGV exit 139, same users=1 accounts=0 DB signature, same "Error" faulting address.

    Setup is two tables + a 50-line .ts; full instructions in the included README.md. Should be the easiest possible reproduction without the full shop-admin tree.

    (2) Sharper crash address — "Error" not "Irror"

    Re-read x19 with debuginfo build: x19 = 0x000000726f727245. I'd off-by-one'd a byte earlier; the actual bytes are 45 72 72 6F 72 = ASCII "Error" (capital E), not "Irror". That's the string "Error" being dereferenced as a Promise/object pointer. The instruction is ldr w26, [x19, #0x4] (load 32-bit at offset 4 — likely a class-id, promise-state, or header field load).

    x21 / x22 both hold 0x0000020000341b08 — a real heap pointer (probably the correct receiver of the method call, surviving in another register). That's a tell that the bug isn't "the whole object went bad"; it's "one specific slot got swapped with a string value."

    Two specific hypotheses to check first

    a. Rejected-promise reason in resolve slot. If somewhere a js_promise_resolve(p, value) is being called with value = errorString (the rejection reason) instead of js_promise_reject(p, reason), the awaiter unwraps it as a resolved value, then dispatches .something() on it. The receiver is "Error" bytes; [receiver+4] faults. The "Error" string is exactly what an error message would start with (new Error("...") produces "Error: ...").

    b. NaN-box mask error on a small/inline string. A unbox_to_i64 path that strips the high 16 bits of a NaN-box without checking the tag would convert a small-string-encoded JSValue into its raw byte payload as a pointer. If perry has any inline-string optimization for short strings (≤5 bytes — "Error" is 5), this hits.

    Symbol resolution

    Still stripped — perry compile's link step -dead_strips the DWARF references even when perry-runtime/perry-stdlib are built with RUSTFLAGS="-C debuginfo=2". The maintainer's local build with full debuginfo + an image lookup --address on the 6 frames in the README should resolve the function names instantly. I confirmed the addresses are stable across reruns (same instruction at +2212 of the same outer function each time).

    Status

    • Local checkout at v0.5.936 (HEAD 70868f9a + 1 build of debug-info perry-runtime/stdlib, no source edits).
    • issue-859-repro/repro.ts + README.md ready to copy into perry's test-files/ if useful.
    • All my prior instrumentation reverted; shop-admin tree clean.

    Let me know if it'd help to (a) run samply / instruments over the repro for a flamegraph of the failing call path, or (b) try to remove argon2 / rate-limiter individually from the repro and see which one is load-bearing. I tried a quick variant matrix but the shell driver had a bug — happy to redo it cleanly.

  5. proggeramlug commented on May 17, 2026

    @proggeramlug
    ContributorAuthor

    MySQL-free 15-line repro

    Bisected the failing signup down from ~250 LOC + 5 native deps + 2 tables + argon2 + rate-limiter + dtToIso + DATETIME columns + Date params + @perryts/mysql to this:

    import Fastify from "fastify";
    const app = Fastify({ logger: false });
    const toUser = (r: any) => null;                            // module-level arrow
    async function delay() { return Promise.resolve(); }
    async function createUser() {
      await delay();
      return toUser(undefined);
    }
    app.get("/healthz", async () => ({ ok: true }));
    app.post("/go", async (_req, reply) => {
      const u = await createUser();
      void reply.code(201);
      return { ok: true, u };
    });
    await app.listen({ host: "127.0.0.1", port: 18099 });
    console.log("listening");
    PERRY_ALLOW_UNIMPLEMENTED=1 perry compile minimal.ts -o /tmp/minimal
    /tmp/minimal &
    sleep 1
    curl -X POST http://127.0.0.1:18099/go
    # → curl: (52) Empty reply from server; exit 139.

    Same exact crash signature as the full shop-admin signup:

    EXC_BAD_ACCESS (code=1, address=0x000000726f727245)
    frame #0:  minimal + 0x10034e5ac    ldr w26, [x19, #0x4]
    (lldb) register read x19
         x19 = 0x000000726f727245   ← bytes 45 72 72 6F 72 = ASCII "Error"
    

    3/3 runs reliably reproduce. tsx minimal.ts returns the correct JSON HTTP 201.

    Load-bearing ingredients (drop any → no crash)

    Found by exhaustive bisect (numbered as v1–v35 against my local checkout):

    Drop / change Result
    Replace arrow const toUser = … with function toUser(…) {…} ALIVE 201
    Inline the arrow as an IIFE ((r)=>null)(undefined) ALIVE 201
    Inline await new Promise(r => setTimeout(r,1)) instead of await delay() ALIVE 201
    Inline await Promise.resolve() (no named async fn) ALIVE 201
    Remove await inside createUser, keep arrow call ALIVE 201
    Remove the nested createUser, inline its body in the route handler ALIVE 201
    Remove Fastify; same code as a top-level main() DONE exit 0
    MySQL + @perryts/mysql + argon2 + rate-limiter + dtToIso + DATETIME cols + Date params none of these are required, all drop without changing the crash

    So the exact minimum trigger is:

    1. Fastify async route handler
    2. that calls a nested user async function
    3. whose body awaits the return of another named async function (not an inline await new Promise(…) and not an await Promise.resolve())
    4. and then calls a module-level const arrow = (x) => … (not a function declaration, not an IIFE)

    That's it. No mysql, no argon2, no rate-limiter, no Dates. Just the resume-from-an-awaited-named-async-fn followed by calling-a-module-level-arrow inside a Fastify-callback-invoked async frame.

    Why this matches the "Error" address

    x19 carries the bytes 45 72 72 6F 72 = ASCII "Error". That has to be coming from somewhere in either:

    • the fastify package source (rustls / hyper error variants are full of Error strings)
    • perry-runtime's internal panic/format strings
    • a new Promise(...) constructor reject-path string

    Whatever it is, it's getting loaded into x19 where the codegen-emitted async-resume + arrow-call dispatch expects a Promise/object pointer — almost certainly an arrow-const's receiver slot or the resumed async-frame's return-value slot getting overwritten with a string reason from a prior reject path. The arrow-vs-function distinction + the "named-async-fn-only" distinction are the strong tells; both touch perry's closure / async-state-machine codegen.

    Files

    issue-859-repro/minimal.ts (the 15-liner above) and MINIMAL.md (full
    context + bisect table) checked in to the user repo. No external setup
    needed — just fastify in node_modules. Happy to PR it into perry's
    test-files/ directory if useful.

    Local checkout still clean at v0.5.936, no source edits, full instrumentation reverted.

  6. added 3 commits that reference this issue on May 17, 2026
  7. proggeramlug commented on May 17, 2026

    @proggeramlug
    ContributorAuthor

    Verified at v0.5.953.

    (a) The 15-line minimal MySQL-free repro now passes: POST /go → {"ok":true,"u":null} HTTP 201, server stays alive. The fix in 0ef026f7 ("module-level const arrow callee inside resumed async-step body") correctly closes the exact shape I'd bisected to.

    (b) The full shop-admin signup still SIGSEGVs, but at a different site with a different signature — significant additional ground gained, one more bug to chase.

    What the fix unblocked

    DB state after one signup at v0.5.953 (vs pre-fix):

    pre-fix post-fix
    users 1 1
    accounts 0 1 ✓
    sessions 0 1 ✓
    members 0 1 ✓
    auditLog 0 0

    createAccount, addMember, issueSession all complete and commit now. Trace via 15 inline console.error checkpoints in the signup route runs cleanly through Z13 session done. The next line is the synchronous const accessToken = issueAccessToken({...}) — crash there.

    New crash signature

    EXC_BAD_ACCESS (code=1, address=0xe6)
    frame #0: server + 0x1001b74cc    ldr w1, [x0, #0x4]
    frame #1: server + 0x10023cb8c
    frame #2: server + 0x100114ffc
    frame #3: server + 0x1001255cc
    frame #4: server + 0x100237674
    ...
    (lldb) register read x19 x20 x21 x22
         x19 = 0x000000016fdfded0   ← stack ptr (looks valid)
         x20 = 0x0000020000269110   ← heap ptr (looks valid)
         x21 = 0x0000020000391a90   ← heap ptr
         x22 = 0x7ffd020000503618   ← perhaps NaN-boxed value (top 16 bits = 0x7ffd)
    

    x0 (the receiver of [x0+4]) holds 0xe6 = 230 — small integer (class ID? property index? count?) being dereferenced as a pointer. Not the same "Error"-string type-confusion as the #859 fix targeted. Different instruction context, different register state, different fault address shape.

    Standalone narrows

    issueAccessToken is one line:

    export function issueAccessToken(claims: AccessTokenClaims): string {
      return jwt.sign(claims, loadKey(), { algorithm: "ES256", expiresIn: 900, issuer: "skelpo-shop-admin" });
    }

    Pulled into a minimal Fastify route with the same shape (async asyncStep → jwt.sign(claims, PEM, {ES256,...})) — passes byte-identical to tsx. So the bug is not in jsonwebtoken.sign itself; it requires the accumulated state from the 6 prior awaits (rate-limiter, getUserByEmail, hashPassword, createUser, createAccount, addMember, issueSession) for the values reaching jwt.sign to carry whatever corruption triggers the deref.

    The most suspect values reaching issueAccessToken:

    Possible the same arrow-via-resumed-async-step shape that #859 fixed has a sibling case for the return values flowing through it (not just the call). Or a separate codegen issue at the function … { return jwt.sign(...); } site combined with the multi-await preamble.

    Status

    #859 itself — the bug it was opened for — is closed-and-verified. The remaining shop-admin signup blocker is a distinct issue. Local checkout at v0.5.953, no source edits, all instrumentation reverted. Filing the new crash separately so the tracking stays clean; will link from this thread once filed.

    Happy to bisect the new site toward a MySQL-free minimal the same way I did for #859 — should be straightforward since I know the failure transition is Z13 (issueSession resolved) → issueAccessToken(...). Should I drive that locally and post the minimal repro to the new issue?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugConfirmed defect or regression

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions