Skip to content

0.33.0: reads inside resolveOutput recurse without bound — server OOMs on any access-scoped read #844

Description

@borisno2

0.33.0: reads inside resolveOutput recurse without bound — server OOMs on any access-scoped read

Regression in 0.33.0 (works on 0.32.0). Introduced by #838 / ADR-0022 (fix for #830).

Upgrading @opensaas/stack-core 0.32.0 → 0.33.0 makes every authenticated page in our app hang and drives the Next.js server to the V8 heap limit. In CI our Playwright server died ~3 minutes into the run:

[2815:0x1ee51000] 185775 ms: Mark-Compact 4073.9 (4105.2) -> 4062.2 (4106.0) MB, pooled: 0 MB,
                            769.07 / 0.01 ms (average mu = 0.304, current mu = 0.237) task;
                            scavenge might not succeed
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
 1: 0x74eae8 node::OOMErrorHandler(char const*, v8::OOMDetails const&) [next-server (v16.2.10)]

59 of 71 e2e tests failed, almost all as ERR_CONNECTION_REFUSED after the process was gone.

Cause

Two changes in #838 interact:

1. access/access-filter.tsbuildIncludeWithAccessControl

0.32 bailed out entirely inside a resolveOutput context:

if (args.context._resolveOutputCounter.depth > 0) {
  return undefined   // no auto-include: the read fetched scalar columns only
}

0.33 replaced that with a flag that still emits a one-level include for every readable relation (access-filter.ts:126):

const insideResolveOutput = args.context._resolveOutputCounter.depth > 0
// …
if (!insideResolveOutput && !visitedLists.includes(relatedListName)) {
  nested = await buildIncludeWithAccessControl(/* … */)
}
include[fieldName] = { where, nested }   // ← relation is included either way

2. access/field-visibility.tsfilterReadableFields

The depth < MAX_DEPTH guard on the relationship recursion was removed, so every returned relation row now runs its own resolveOutput hooks at any depth.

Why the guards don't hold

_resolveOutputCounter.depth is only ever read as a boolean — it is incremented/decremented in filterReadableFields but never compared against a cap. So it flags "am I inside a hook?" and never bounds how deep the nesting goes.

The two code comments justifying the change each rely on an assumption that the other change invalidates:

access-filter.ts: "no recursive call is made, so a self-referential relation terminates after one level regardless of cycles"

True of buildIncludeWithAccessControl's own recursion — but the loop doesn't run through it. It runs through the hook↔read cycle: the fetched relation rows re-enter filterReadableFields, which executes their resolveOutput hooks, each of which issues a brand-new top-level read that builds a fresh one-level include. Every turn of that cycle is a new query and a new row set.

field-visibility.ts: "by the time a result reaches this function it is already a finite, acyclic tree whose depth was decided at the pre-query phase"

True for the tree produced by one query. But the resolveOutput hooks executed during the walk issue new queries producing new trees, so the total depth is not bounded by the pre-query phase.

On a cyclic readable-relationship graph the cycle never closes, and each turn allocates another row set.

Minimal reproduction

Sketch, reduced from the real failure — I confirmed the mechanism against our app (see below), not against this exact schema in the stack test suite.

User: list({
  fields: {
    accounts: relationship({ ref: 'Account.user', many: true }),
    name: virtual({
      type: 'string',
      hooks: {
        resolveOutput: async ({ item, context }) => {
          const [a] = await context.db.account.findMany({ where: { userId: item.id }, take: 1 })
          return `${a?.firstName}`
        },
      },
    }),
  },
}),
Account: list({
  fields: {
    firstName: text(),
    user: relationship({ ref: 'User.accounts' }),
    students: relationship({ ref: 'Student.account', many: true }),
  },
}),
Student: list({
  fields: {
    account: relationship({ ref: 'Account.students' }),
    label: virtual({
      type: 'string',
      hooks: {
        resolveOutput: async ({ item, context }) => {
          const a = await context.db.account.findUnique({ where: { id: item.accountId } })
          return `${a?.firstName}`
        },
      },
    }),
  },
}),

Then, on an access-scoped (non-sudo) context:

await context.db.user.findMany({})

Trace:

  1. user.findMany auto-includes to depth 5 → returns users with accountsstudents.
  2. filterReadableFields walks the rows and runs User.nameaccount.findMany. Now insideResolveOutput, so that read returns the Account plus user and students.
  3. Those Student rows are walked (no depth cap now) → Student.labelaccount.findUnique → again returns Account + user + students.
  4. → step 3, forever.

Sudo contexts are unaffected — context/index.ts uses the caller's include as-is and builds no auto-include.

Evidence

Measured on a real app (~40 lists, cyclic relationship graph), Next 16.2.10, in-process PGlite:

stack-core GET /dashboard (one request)
0.33.0, stock never returns; RSS 835 MB → 1.5 GB → 3.2 GB → 4.4 GB, still 290% CPU after the client disconnects
0.33.0 + one-line patch below 200 in 5 s; steady state 1.8 s, memory flat
0.32.0 unaffected — same suite, 70/70 e2e green

The patch, applied to node_modules purely to isolate the cause (restores the 0.32 behaviour):

const insideResolveOutput = args.context._resolveOutputCounter.depth > 0
if (insideResolveOutput) return { kind: 'nothing-to-scope' }   // ← added

Workaround for consumers

Pass an explicit empty include on every read issued inside a resolveOutput hook:

await context.db.account.findMany({ where: { userId: item.id }, take: 1, include: {} })

mergeIncludeWithAccessControl only walks caller-named keys, so {} fetches scalars and nothing else, and the chain terminates. Verified against stock 0.33.0.

This is per-call-site, though — one unguarded read inside a resolveOutput anywhere in the app brings the runaway back.

Suggested fix

The #830 goal (row-scope a relation read from inside a hook rather than skipping scoping) seems compatible with terminating, if the recursion is bounded somewhere it can actually see the hook↔read cycle. Options, roughly in order of how surgical they are:

  1. Use _resolveOutputCounter.depth as a real cap rather than a boolean — refuse to auto-include (or to run nested resolveOutput hooks) past depth 1.
  2. Don't run resolveOutput hooks on rows fetched inside a resolveOutput context — scope and return them, but don't recurse into their virtuals.
  3. Keep filterReadableFields's recursion bounded by a cap tied to the cumulative hook depth, not per-query depth.

Happy to test a patch against our suite — it reproduces reliably and fails within seconds.

Versions

  • @opensaas/stack-core 0.33.0 (also stack-auth / stack-ui / stack-storage 0.33.0)
  • next 16.2.10, react 19.2.4, @prisma/client 7.9.1, Node 24

Metadata

Metadata

Assignees

Labels

agent-handoffbugSomething isn't workingready-for-agentFully specified, ready for an AFK agent

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions