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.
Symptom
A 1Hz `setInterval` that ticks an async function reading from MySQL via `@perryts/mysql` wedges the perry runtime:
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
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:
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.