Skip to content

v0.5.1009 regression: setInterval+async MySQL query loop pegs CPU and wedges Fastify HTTP #1114

Description

@proggeramlug

Symptom

A 1Hz `setInterval` that ticks an async function reading from MySQL via `@perryts/mysql` wedges the perry runtime:

  • CPU pins at 99% steady-state
  • `/healthz` (and every other route) times out — Fastify accepts TCP connections but never writes a response
  • No `fatal:` error, no crash, no log output during the wedge
  • Profile sample (`sample 3`) shows the hot path is `madvise()` syscalls deep inside the perry runtime — GC thrashing

Same shop-admin code, same SQL queries, same `@perryts/mysql` ran cleanly on v0.5.1008 earlier today. v0.5.1009 wedges within 1–2 tick intervals.

Bisect

  • Boot completes all 7 steps including `ready`
  • HTTP responds for the first few requests within the warm window... actually no, `/healthz` times out from the first request after `ready`
  • Commenting out `new JobLoop().start()` in shop-admin's `server/main.ts` → boot is clean, HTTP responds in <1s, CPU 0%
  • Re-enabling `new JobLoop().start()` → CPU → 99% within 1s, HTTP wedges

The JobLoop is:

```ts
class JobLoop {
private running = false;
start(): void {
this.timer = setInterval(() => void this.tick(), 1000);
}
private async tick(): Promise {
if (this.running || this.stopping) return;
this.running = true;
try {
const jobs = await claimJobs(this.workerId, 10); // ← @perryts/mysql query w/ FOR UPDATE SKIP LOCKED
for (const job of jobs) await this.runOne(job);
} finally {
this.running = false;
}
}
}
```

`claimJobs` does a `SELECT … FOR UPDATE SKIP LOCKED` then `UPDATE` to atomically grab pending rows. On a fresh DB with no jobs, it returns an empty array.

Minimal repro (didn't reproduce)

```ts
import Fastify from "fastify";
const app = Fastify({ logger: false });
app.get("/healthz", async () => ({ ok: true }));
await app.listen({ host: "127.0.0.1", port: 18099 });

class Loop {
private running = false;
start(): void { setInterval(() => void this.tick(), 1000); }
private async tick(): Promise {
if (this.running) return;
this.running = true;
try { await new Promise((r) => setTimeout(r, 10)); }
finally { this.running = false; }
}
}
new Loop().start();
```

This does not reproduce — CPU 0%, healthz 200. So the trigger is something more specific:

  • The MySQL query in the tick (`@perryts/mysql` is a pure-TS driver — compiled by perry, not a native binding)
  • Or the volume of code in the surrounding compile unit (shop-admin has ~68 server TS files)
  • Or a specific HIR/codegen change in v0.5.1009 that surfaces only at scale

Adding `@perryts/mysql` to my repro might catch it; haven't traced further yet because the bisect was clear and the user's first-Shopware-connect deadline is hot.

v0.5.1008 → v0.5.1009 diff highlights

(from `git log origin/main`)

```
b6b790c fix(hir): #1070 — setErrorHandler err param mis-tagged as fastify Request (#1077)
92f28e2 feat(crypto): wire createCipheriv/createDecipheriv for AES-256-CBC + AES-256-GCM (#1075)
35cf1b4 fix(codegen): refs #420 #618 — narrow v0.5.758 walk-stop to leaf-only with fields
0ee7f07 fix(hir): closes #542 #543 followup — m.keys()/m.values() on Map|undefined narrowed param
bd9e897 llvm-backend: union narrowing fixes — null/undef compare, toString dispatch, strict string concat
430ade1 fix: narrow IndexGet string key detection to avoid regression
```

The HIR narrowing / union narrowing changes are the most likely suspects given the GC-thrash signature.

Sample (perry v0.5.1009 wedged server)

```
2140 thread_start (in libsystem_pthread.dylib)
2140 Thread_: tokio-rt-worker (×4)
2140 Thread_
: com.apple.main-thread
… ??? (in server) … ??? (in server) … madvise (in libsystem_kernel.dylib)
```

The deepest leaf is consistently `madvise`. With a stripped binary I can't trace which perry-runtime function is calling `madvise` in a tight loop, but the pattern fits the perry runtime's free-list returning pages to the OS over and over.

Workaround used

Commented out `new JobLoop().start()` in shop-admin's main.ts. Cron schedules are fine to keep registered (they don't fire on the wall clock during a typical test session). This unblocks the first Shopware-6 connect e2e while the regression gets sorted.

Pointer

Either a GC trigger in the HIR-narrowed Map/iter code path, or a tight allocation loop in the new union-narrowing code path. `bd9e8977` and `35cf1b43` are the most likely culprits to revert-test.

Local checkout: perry 0.5.1009, clean main, no edits.

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