Skip to content

Native route returns body 0 + HTTP 200 after sequence of @perryts/mysql writes; first INSERT commits, rest silently no-op, explicit return dropped #748

@proggeramlug

Description

@proggeramlug

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:

  1. @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.
  2. 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."
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions