Filed as follow-up to #859 (which is fixed for what it targeted — minimal arrow-after-resumed-async-step repro now passes byte-identical to tsx). The shop-admin signup at v0.5.953 still SIGSEGVs, but at a different site with a different crash signature, and I've now reduced it to an 11-line MySQL-free standalone.
Minimal repro (11 lines, no MySQL, no @perryts/*, no argon2)
import Fastify from "fastify";
import jwt from "jsonwebtoken";
const app = Fastify({ logger: false });
async function delay() { return Promise.resolve(); }
async function getThing() { await delay(); return undefined; }
app.get("/healthz", async () => ({ ok: true }));
app.post("/go", async (_req, reply) => {
await getThing();
const token = jwt.sign({ sub: "x" }, "secret", { algorithm: "HS256" });
void reply.code(201);
return { ok: true, len: token.length };
});
await app.listen({ host: "127.0.0.1", port: 18099 });
console.log("listening");
PERRY_ALLOW_UNIMPLEMENTED=1 perry compile jwt-min.ts -o /tmp/jwt-min
/tmp/jwt-min &
sleep 1
curl -X POST http://127.0.0.1:18099/go
# → curl: (52) Empty reply from server; exit 139.
tsx jwt-min.ts returns {"ok":true,"len":89} HTTP 201. 3/3 native runs reliably SIGSEGV.
Crash signature
thread #1, EXC_BAD_ACCESS (code=1, address=0x39edd942)
frame #0: jwt-min + 0x10000a4b0 ldr w1, [x0, #0x4]
(lldb) register read x0
x0 = 0x0000000039edd93e ← 32-bit garbage interpreted as object pointer
Same instruction (ldr w1, [x0, #0x4]) and same dereference pattern as the shop-admin signup crash (which fired at address=0xe6). Different small-int garbage in x0 per run, but the load-32-bit-at-offset-4-of-x0 shape is identical.
Load-bearing ingredients (drop any → no crash)
Numbered against my local bisect (m1–m5 + k variants in the issue-859-repro/ tree of the user repo):
| Change |
Result |
Replace jwt.sign with validator.isEmail (also a NATIVE_MODULES binding) in the exact same shape |
ALIVE 201 |
Replace jwt.sign with a top-level function syncWork(s) {...} |
ALIVE 201 |
Replace await getThing() with await delay() inline (drop the nested fn) |
ALIVE 201 |
Replace await getThing() with await Promise.resolve() inline |
ALIVE 201 (server stays up, request hangs) |
import * as jwt (namespace) vs import jwt (default) |
both fail with same crash |
import { sign } (named, call sign(...) directly) |
fails identically |
jwt.sign("string", ...) vs jwt.sign({...}, ...) |
both fail |
| HS256 vs ES256 algorithm |
both fail |
So the exact minimum trigger is:
- Fastify async route handler
- Nested user async function awaited from the handler (
await getThing()), whose body itself awaits another named async function (await delay())
- A
jsonwebtoken sign call after that — call shape doesn't matter (default-import method, named-import, namespace; HS256, ES256; string or object claims).
The bug is specific to jsonwebtoken as the imported callee; validator.isEmail (also in perry_api_manifest::NATIVE_MODULES) in the exact same surrounding shape does not crash. So this is a jsonwebtoken perry-stdlib binding interacting badly with the resumed-async-step body — analogous to #859's "module-level const arrow callee" pattern but for a NATIVE_MODULES binding rather than a user-defined arrow.
What I've ruled out
Files
issue-859-repro/jwt-min.ts in the user repo, alongside the previously-committed minimal repro for the closed #859 case (minimal.ts, now passing). No external setup — just fastify + jsonwebtoken in node_modules, perry 0.5.953.
Note on the original shop-admin signup
Same instruction, same crash class, same surrounding 7-await preamble. DB after the crash now shows users=1, accounts=1, sessions=1, members=1, auditLog=0 — createUser through issueSession all commit, issueAccessToken (the jwt.sign call) is where it dies. So fixing this should close the residual shop-admin blocker.
Local checkout: clean at perry HEAD (v0.5.953), no source edits.
Filed as follow-up to #859 (which is fixed for what it targeted — minimal arrow-after-resumed-async-step repro now passes byte-identical to tsx). The shop-admin signup at v0.5.953 still SIGSEGVs, but at a different site with a different crash signature, and I've now reduced it to an 11-line MySQL-free standalone.
Minimal repro (11 lines, no MySQL, no @perryts/*, no argon2)
tsx jwt-min.tsreturns{"ok":true,"len":89}HTTP 201. 3/3 native runs reliably SIGSEGV.Crash signature
Same instruction (
ldr w1, [x0, #0x4]) and same dereference pattern as the shop-admin signup crash (which fired ataddress=0xe6). Different small-int garbage inx0per run, but the load-32-bit-at-offset-4-of-x0 shape is identical.Load-bearing ingredients (drop any → no crash)
Numbered against my local bisect (m1–m5 + k variants in the issue-859-repro/ tree of the user repo):
jwt.signwithvalidator.isEmail(also a NATIVE_MODULES binding) in the exact same shapejwt.signwith a top-levelfunction syncWork(s) {...}await getThing()withawait delay()inline (drop the nested fn)await getThing()withawait Promise.resolve()inlineimport * as jwt(namespace) vsimport jwt(default)import { sign }(named, callsign(...)directly)jwt.sign("string", ...)vsjwt.sign({...}, ...)So the exact minimum trigger is:
await getThing()), whose body itself awaits another namedasync function(await delay())jsonwebtokensigncall after that — call shape doesn't matter (default-import method, named-import, namespace; HS256, ES256; string or object claims).The bug is specific to
jsonwebtokenas the imported callee;validator.isEmail(also inperry_api_manifest::NATIVE_MODULES) in the exact same surrounding shape does not crash. So this is ajsonwebtokenperry-stdlib binding interacting badly with the resumed-async-step body — analogous to #859's "module-level const arrow callee" pattern but for a NATIVE_MODULES binding rather than a user-defined arrow.What I've ruled out
await getThing()is enough."string"claims fails the same.Files
issue-859-repro/jwt-min.tsin the user repo, alongside the previously-committed minimal repro for the closed #859 case (minimal.ts, now passing). No external setup — justfastify+jsonwebtokeninnode_modules, perry 0.5.953.Note on the original shop-admin signup
Same instruction, same crash class, same surrounding 7-await preamble. DB after the crash now shows
users=1, accounts=1, sessions=1, members=1, auditLog=0—createUserthroughissueSessionall commit,issueAccessToken(thejwt.signcall) is where it dies. So fixing this should close the residual shop-admin blocker.Local checkout: clean at perry HEAD (v0.5.953), no source edits.