Native-compiled Fastify route returns body 0 + HTTP 200 after multi-step await chain on @perryts/mysql; first DB write succeeds, subsequent writes silently no-op, explicit return { ... } is dropped
Surfaced while verifying the v0.5.899 #665 fix on the same project. The original rate-limiter crash is gone; the native server boots and routes through cjs_wrap'd rate-limiter-flexible cleanly. What I'm hitting now appears to be a separate, deeper bug in async-await control flow when stacked MySQL writes happen inside a Fastify route handler.
perry: 0.5.899 (HEAD at d8c925a7)
Runtime stack: Fastify 4.28 (in compilePackages) + @perryts/mysql (perry-native MySQL driver) + argon2 + jsonwebtoken
Comparison baseline: tsx server/main.ts returns the correct JSON response from the same source code, against the same database.
Reproduction
POST /v1/auth/signup in skelpo-shop-admin (the same project that drove #665):
app.post("/v1/auth/signup", async (req, reply) => {
await checkSignup(req); // rate-limiter (now works at v0.5.899)
const body = (req.body ?? {}) as SignupBody;
if (!body.email || !validator.isEmail(body.email)) throw badRequest("invalid_email");
if (!body.password || body.password.length < 12) throw badRequest("invalid_password", "...");
const existing = await getUserByEmail(body.email);
if (existing) throw conflict("email_in_use");
const passwordHash = await hashPassword(body.password); // argon2
const user = await createUser({ email: body.email, passwordHash }); // ← runs, row written
const account = await createAccount({ name: ..., trialEndsAt: ... }); // ← silently does nothing
await addMember(account.id, user.id, "owner", null); // ← silently does nothing
const session = await issueSession({ ... }); // ← silently does nothing
const accessToken = issueAccessToken({ ... }); // sync — JWT signing
await writeAuditEntry({ ... }); // ← silently does nothing
void reply.code(201);
return {
accessToken,
refreshToken: session.refreshToken,
expiresAt: session.expiresAt.toISOString(),
user: { id: user.id, email: user.email },
account: { id: account.id, name: account.name, plan: account.plan, trialEndsAt: account.trialEndsAt },
};
});
Native (./build/server, perry 0.5.899)
$ curl -sS -w 'HTTP %{http_code}\nContent-Type: %{content_type}\nSize: %{size_download} bytes\n' \
-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"}'
0
HTTP 200
Content-Type: application/json
Size: 1 bytes
$ # hex of the response body:
$ curl -sS http://.../signup -d '...' | xxd
00000000: 30 0
The wire byte is literally ASCII 0 (one byte). HTTP 200, not the 201 the route sets. No exception, no 4xx, no log line.
tsx baseline (same source, same DB)
$ tsx server/main.ts
$ curl ... /v1/auth/signup -d '...'
{"accessToken":"eyJhbGciOiJFUzI1NiI...","refreshToken":"prjEL0p5...","expiresAt":"2026-08-11T07:20:41.720Z","user":{...},"account":{...}}
HTTP 201
What's running and what isn't
After hitting the native signup endpoint with two distinct emails:
mysql> SELECT email FROM users;
| user1@test.com |
| user2@test.com |
mysql> SELECT COUNT(*) FROM accounts; -- ← 0
mysql> SELECT COUNT(*) FROM sessions; -- ← 0
mysql> SELECT COUNT(*) FROM auditLog; -- ← 0
mysql> SELECT COUNT(*) FROM members; -- ← 0
So the first await pool.exec("INSERT INTO users ...") ran to completion (rows visible). Every subsequent await pool.exec(...) after that silently did nothing — no row, no error, no log.
The hex 30 body + HTTP 200 strongly suggests the async function returned 0 (which JSON-serializes to literally 0) instead of the explicit return object. Pattern: like the function body bails after the second await pool.exec(...) and the runtime resolves the outer promise to the number 0 rather than propagating the throw or honoring the explicit return { ... }.
/v1/auth/signin shows the same shape — body 0, HTTP 200, no session row written — even though its handler has no writes before its return.
What I already ruled out
I wrote a minimal Fastify probe (compilePackages: ["fastify"] against the same node_modules/fastify) covering each pattern from the route in isolation:
app.get("/object", async () => ({ hello: "world", num: 42 })); // ✓ {"hello":"world","num":42} HTTP 200
app.get("/string", async () => "plain string"); // ✓ HTTP 200
app.get("/number", async () => 7); // ✓ HTTP 200
app.post("/b", async (_req, reply) => { // ✓ {"x":2,"hello":"world"} HTTP 201
void reply.code(201);
return { x: 2, hello: "world" };
});
app.post("/c", async (_req, reply) => { // ✓ {"x":3,"a":42,"b":42} HTTP 201
const a = await delay();
const b = await delay();
void reply.code(201);
return { x: 3, a, b };
});
All four pass byte-identical to tsx under perry 0.5.899. So the bug is not about:
- bare object returns from async handlers
void reply.code(N) semantics
- multiple sequential
await calls in isolation
What changes is when the awaited operations are @perryts/mysql pool exec/query calls that mutate state on the same connection pool. The first write commits; the second onwards silently no-ops; the explicit return value is replaced with 0 on the wire.
Open question for the maintainer
Possibilities, ordered by my prior:
@perryts/mysql's second-call-on-same-pool path returns a Promise that perry's await resolves to 0 (rowsAffected? handle? something default-initialized) AND the rejection-or-continuation chain gets corrupted such that the route's explicit return is dropped.
- There's a panic / abort in perry runtime that gets caught somewhere and converted to a
200 0 response. The fact that fastify's own LOG_LEVEL=trace produces zero per-request log lines (the only output is the startup banner) supports "the handler never reaches fastify's reply path."
- Some specific shape in argon2 / jsonwebtoken / Date.toISOString that interacts with later awaits.
Happy to wire a tighter standalone minimal repro if useful — would need @perryts/mysql linkage which is non-trivial outside the project. The route source above + the running shop-admin DB are sufficient locally.
Context
This is the same project that drove #665 across 11+ days. The v0.5.899 fixes for cjs_wrap + native-routing landed cleanly — that path is verified working. Filing this as a separate bug since the symptoms, code path, and likely root cause don't overlap with rate-limiter/CJS-class identity.
Native server now boots, applies migrations, reaches Server listening, handles /healthz + /readyz, and reaches the signup route (where the original crash used to fire) — and gets much further than before — but stops short of returning the response payload the route builds. The user is unblocked on the original blocker, blocked on this new one.
Native-compiled Fastify route returns body
0+ HTTP 200 after multi-stepawaitchain on @perryts/mysql; first DB write succeeds, subsequent writes silently no-op, explicitreturn { ... }is droppedSurfaced while verifying the v0.5.899 #665 fix on the same project. The original rate-limiter crash is gone; the native server boots and routes through cjs_wrap'd
rate-limiter-flexiblecleanly. What I'm hitting now appears to be a separate, deeper bug in async-await control flow when stacked MySQL writes happen inside a Fastify route handler.perry: 0.5.899 (HEAD at
d8c925a7)Runtime stack: Fastify 4.28 (in
compilePackages) +@perryts/mysql(perry-native MySQL driver) + argon2 + jsonwebtokenComparison baseline:
tsx server/main.tsreturns the correct JSON response from the same source code, against the same database.Reproduction
POST /v1/auth/signupin skelpo-shop-admin (the same project that drove #665):Native (
./build/server, perry 0.5.899)The wire byte is literally ASCII
0(one byte). HTTP 200, not the201the route sets. No exception, no4xx, no log line.tsx baseline (same source, same DB)
What's running and what isn't
After hitting the native signup endpoint with two distinct emails:
So the first
await pool.exec("INSERT INTO users ...")ran to completion (rows visible). Every subsequentawait pool.exec(...)after that silently did nothing — no row, no error, no log.The hex
30body + HTTP 200 strongly suggests the async function returned0(which JSON-serializes to literally0) instead of the explicit return object. Pattern: like the function body bails after the secondawait pool.exec(...)and the runtime resolves the outer promise to the number0rather than propagating the throw or honoring the explicitreturn { ... }./v1/auth/signinshows the same shape — body0, HTTP 200, no session row written — even though its handler has no writes before its return.What I already ruled out
I wrote a minimal Fastify probe (
compilePackages: ["fastify"]against the samenode_modules/fastify) covering each pattern from the route in isolation:All four pass byte-identical to
tsxunder perry 0.5.899. So the bug is not about:void reply.code(N)semanticsawaitcalls in isolationWhat changes is when the awaited operations are
@perryts/mysqlpoolexec/querycalls that mutate state on the same connection pool. The first write commits; the second onwards silently no-ops; the explicitreturnvalue is replaced with0on the wire.Open question for the maintainer
Possibilities, ordered by my prior:
@perryts/mysql's second-call-on-same-pool path returns a Promise that perry's await resolves to0(rowsAffected? handle? something default-initialized) AND the rejection-or-continuation chain gets corrupted such that the route's explicitreturnis dropped.200 0response. The fact that fastify's ownLOG_LEVEL=traceproduces zero per-request log lines (the only output is the startup banner) supports "the handler never reaches fastify's reply path."Happy to wire a tighter standalone minimal repro if useful — would need
@perryts/mysqllinkage which is non-trivial outside the project. The route source above + the running shop-admin DB are sufficient locally.Context
This is the same project that drove #665 across 11+ days. The v0.5.899 fixes for cjs_wrap + native-routing landed cleanly — that path is verified working. Filing this as a separate bug since the symptoms, code path, and likely root cause don't overlap with rate-limiter/CJS-class identity.
Native server now boots, applies migrations, reaches
Server listening, handles/healthz+/readyz, and reaches the signup route (where the original crash used to fire) — and gets much further than before — but stops short of returning the response payload the route builds. The user is unblocked on the original blocker, blocked on this new one.