Skip to content

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

Description

@dalibor67

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:

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.

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