QwikDev / QwikDev/qwik

[🐞] Infinite loop in catch-all route matcher on paths containing `//` (server hangs at 100% CPU, no crash)

Open Beginner friendly
#8,918 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
22.1k
Forks
1.4k
Avg merge
1d 10h
Merged PRs (30d)
52

Description

Which component is affected?

Qwik City (routing)

Describe the bug

recursiveScan() in the catch-all route matcher enters an infinite loop when the request path contains a repeated slash (//), as long as at least one route in the app has a catch-all segment ([...param]).

This is not a crash. No exception is thrown, the process does not exit, and nothing is logged. The Node process simply pins one CPU core at 100% and permanently stops serving requests — including all subsequent, perfectly valid ones. The listening socket stays open, so the kernel keeps completing TCP handshakes without accept(). Any L4/TCP health check therefore reports the service as healthy while it serves nothing.

In our case a single bot request took down a production storefront for 40+ minutes; it only recovered after a manual container restart. Because there is no crash and no log line, nothing in the stack indicated a problem.

Root cause

packages/qwik-city/src/runtime/src/routing.ts — in the published package lib/index.qwik.mjs, lines 410–440:

function recursiveScan(paramName, suffix, path, pathStart, pathLength, route, routeStart, routeLength) {
  if (path.charCodeAt(pathStart) === 47) { pathStart++; }
  let pathIdx = pathLength;
  const sep = suffix + "/";
  while (pathIdx >= pathStart) {
    const match = matchRoutePart(route, routeStart, routeLength, path, pathIdx, pathLength);
    if (match) { /* … */ return match; }
    const newPathIdx = lastIndexOf(path, pathStart, sep, pathIdx, pathStart - 1) + sep.length;
    if (pathIdx === newPathIdx) { break; }   // ← only catches a stalled index
    pathIdx = newPathIdx;                    // ← may move FORWARD
  }
  return null;
}

function lastIndexOf(text, start, match, searchIdx, notFoundIdx) {
  let idx = text.lastIndexOf(match, searchIdx);
  if (idx == searchIdx - match.length) {
    idx = text.lastIndexOf(match, searchIdx - match.length - 1);
  }
  return idx > start ? idx : notFoundIdx;
}

The scan is meant to walk backwards through the path, one separator at a time. With // in the path, lastIndexOf() alternates between two values, so pathIdx oscillates instead of decreasing — it moves backward, then forward, then backward again.

For path = "/produkt//a-b.html", pathStart = 1, sep = "/":

pathIdx text.lastIndexOf("/", pathIdx) idx == searchIdx - 1? returns newPathIdx
10 9 yes → retries from 8 → 8 8 9
9 9 no 9 10
10 9

The guard if (pathIdx === newPathIdx) only detects a stalled index, never a two-element cycle, so the loop never terminates.

Reproduction

Both functions below are copied verbatim from @builder.io/qwik-city@1.20.0 (lib/index.qwik.mjs). Only the call to matchRoutePart is omitted, since the defect is purely in how pathIdx advances:

function lastIndexOf(text, start, match, searchIdx, notFoundIdx) {
  let idx = text.lastIndexOf(match, searchIdx);
  if (idx == searchIdx - match.length) {
    idx = text.lastIndexOf(match, searchIdx - match.length - 1);
  }
  return idx > start ? idx : notFoundIdx;
}

function scanLoop(path, pathStart = 1, suffix = "", maxSteps = 14) {
  const sep = suffix + "/";
  let pathIdx = path.length, trace = [];
  for (let i = 0; i < maxSteps; i++) {
    trace.push(pathIdx);
    const newPathIdx = lastIndexOf(path, pathStart, sep, pathIdx, pathStart - 1) + sep.length;
    if (pathIdx === newPathIdx) return { trace, verdict: "terminates" };
    pathIdx = newPathIdx;
    if (pathIdx < pathStart) return { trace, verdict: "terminates" };
  }
  return { trace, verdict: "INFINITE LOOP" };
}

for (const p of ["/produkt/a-b.html", "/produkt//a-b.html", "/a//b", "/x/y//z/w"]) {
  const r = scanLoop(p);
  console.log(`${p.padEnd(22)} pathIdx: ${r.trace.join(" -> ")}  => ${r.verdict}`);
}

Output:

/produkt/a-b.html      pathIdx: 17 -> 9 -> 1              => terminates
/produkt//a-b.html     pathIdx: 18 -> 10 -> 9 -> 10 -> 9  => INFINITE LOOP
/a//b                  pathIdx: 5 -> 4 -> 3 -> 4 -> 3     => INFINITE LOOP
/x/y//z/w              pathIdx: 9 -> 8 -> 6 -> 5 -> 6 -> 5 => INFINITE LOOP

Any path containing // loops — the position of the repeated slash does not matter.

Steps to reproduce
  1. Create a Qwik City app that has at least one route with a catch-all segment, e.g. src/routes/[...lang]/product/[slug]/index.tsx. (The default starter has no catch-all route, which is likely why this went unnoticed.)
  2. Build and run the production server (entry.express / entry.node-server).
  3. Request any path with a repeated slash, e.g. GET /product//some-item.html.
  4. The request never completes. The process now uses 100% of one CPU core and stops answering all further requests, including previously working ones. It never recovers on its own.

A stack sample taken from the stuck process (V8 inspector, Debugger.pause) shows the loop:

#0  matchRoutePart
#1  recursiveScan
#2  matchRoutePart
#3  matchRoute
#4  loadRoute        ← 99.7% self time in the CPU profile
#5  loadRequestHandlers
#6  resolveRequestHandlers
#7  router
Relation to previously reported issues

This looks superficially similar to two closed issues, but the failure mode is different:

  • #6004 — TypeError [ERR_INVALID_URL] on trailing slashes; a crash, fixed in 2024.
  • #6840 — closed as a duplicate of #6004 the same day it was filed.

Both were about the server throwing. This one hangs: no exception, no log output, process stays alive and is reported healthy by TCP health checks. It also requires a catch-all route, which neither reproduction had. Verified still present in 1.20.0 (current latest).

Suggested fix

Backtracking must be monotonic — the index may never move forward:

-    if (pathIdx === newPathIdx) { break; }
+    if (newPathIdx >= pathIdx) { break; }

Normalising repeated slashes before routing would also help, but the loop guard should be correct regardless of input.

System Info
@builder.io/qwik-city: 1.20.0 (current latest on npm)
@builder.io/qwik:      1.20.0
Node:                  v24.16.0
Server:                entry.express, production build
OS:                    Linux (Docker container)
Additional Information

The impact is amplified by the silence of the failure. Because the process neither exits nor logs, orchestrators do not restart it: Docker Swarm sees a running container, and an L4 health check sees an open port. We measured L4OK from HAProxy at the same moment the socket had 14 connections sitting unaccepted in its backlog queue. Switching to an HTTP-level health check is what finally made the condition visible.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in packages/qwik-city/src/runtime/src/routing.ts, focusing on recursiveScan and its lastIndexOf helper; run the supplied scanLoop reproduction to compare repeated-slash and normal paths. Done means catch-all routing terminates for paths containing // without changing normal backtracking behavior, and the reproduction no longer reports an infinite loop.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
backend, web-dev
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
85/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.