From 56ba7327fc9f8b3a60713d5cae4bd2f44163180f Mon Sep 17 00:00:00 2001 From: "Liam C." Date: Fri, 13 Mar 2026 21:52:22 +0100 Subject: [PATCH 1/2] fix: prevent stack overflow in earlyResolve() on circular dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit earlyResolve() runs during finishInit() — before finishLoad() where findGraphCycles() provides cycle detection. Without guards, circular dependencies among env flag items or @import enabled dependencies cause unbounded recursion and a process crash. Add an isResolved idempotency guard (matching resolve()), and a Set-based cycle detection parameter following the same recursionStack DFS pattern used in findGraphCycles(). Wrap the DirectoryDataSource call site in try/catch so cycle errors surface as loading errors instead of uncaught exceptions. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../varlock/src/env-graph/lib/config-item.ts | 19 +++++++-- .../varlock/src/env-graph/lib/data-source.ts | 9 +++- .../src/env-graph/test/environments.test.ts | 42 +++++++++++++++++++ 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/packages/varlock/src/env-graph/lib/config-item.ts b/packages/varlock/src/env-graph/lib/config-item.ts index 624ab02c..439893c7 100644 --- a/packages/varlock/src/env-graph/lib/config-item.ts +++ b/packages/varlock/src/env-graph/lib/config-item.ts @@ -269,18 +269,31 @@ export class ConfigItem { * special early resolution helper * currently used to resolve the envFlag before everything else has been loaded * */ - async earlyResolve() { + async earlyResolve(resolving?: Set) { + if (this.isResolved) return; + + // Cycle detection: track which items are currently in the resolution chain + // (mirrors the recursionStack pattern in findGraphCycles) + const resolvingSet = resolving ?? new Set(); + if (resolvingSet.has(this.key)) { + throw new SchemaError( + `Circular dependency detected during early resolution: ${this.key}`, + ); + } + resolvingSet.add(this.key); + await this.process(); // process and resolve any other items our env flag depends on for (const depKey of this.dependencyKeys) { const depItem = this.envGraph.configSchema[depKey]; if (!depItem) { - throw new Error(`eager resolution eror - non-existant dependency: ${depKey}`); + throw new Error(`eager resolution error - non-existent dependency: ${depKey}`); } - await depItem.earlyResolve(); + await depItem.earlyResolve(resolvingSet); } await this.resolve(); + resolvingSet.delete(this.key); } _isRequired: boolean = true; diff --git a/packages/varlock/src/env-graph/lib/data-source.ts b/packages/varlock/src/env-graph/lib/data-source.ts index 60fd84b2..571e766d 100644 --- a/packages/varlock/src/env-graph/lib/data-source.ts +++ b/packages/varlock/src/env-graph/lib/data-source.ts @@ -754,7 +754,14 @@ export class DirectoryDataSource extends EnvGraphDataSource { } const envFlagItem = this.graph.configSchema[envFlagKey]; if (envFlagItem) { - if (!envFlagItem.resolvedValue) await envFlagItem.earlyResolve(); + if (!envFlagItem.resolvedValue) { + try { + await envFlagItem.earlyResolve(); + } catch (err) { + this._loadingError = err instanceof Error ? err : new Error(String(err)); + return; + } + } currentEnv = envFlagItem.resolvedValue?.toString(); } } diff --git a/packages/varlock/src/env-graph/test/environments.test.ts b/packages/varlock/src/env-graph/test/environments.test.ts index e2bab8f1..1b077112 100644 --- a/packages/varlock/src/env-graph/test/environments.test.ts +++ b/packages/varlock/src/env-graph/test/environments.test.ts @@ -364,6 +364,48 @@ describe('@currentEnv and .env.* file loading logic', () => { }); }); +describe('earlyResolve cycle detection', () => { + test('self-referencing env flag triggers loading error', envFilesTest({ + files: { + '.env.schema': outdent` + # @currentEnv=$APP_ENV + # --- + APP_ENV=$APP_ENV + `, + }, + loadingError: true, + })); + + test('indirect cycle via env flag triggers loading error', envFilesTest({ + files: { + '.env.schema': outdent` + # @currentEnv=$APP_ENV + # --- + APP_ENV=$OTHER + OTHER=$APP_ENV + `, + }, + loadingError: true, + })); + + test('diamond dependency (no cycle) in env flag resolves correctly', envFilesTest({ + files: { + '.env.schema': outdent` + # @currentEnv=$APP_ENV + # --- + APP_ENV=fallback($A, $B) + A=fallback($SHARED, a-default) + B=fallback($SHARED, b-default) + SHARED=dev + `, + }, + expectValues: { + APP_ENV: 'dev', + SHARED: 'dev', + }, + })); +}); + describe('multiple data-source handling', () => { test('undefined handling for overriding values', envFilesTest({ files: { From 180c75a41ff27da33e1218c38f65da529568019f Mon Sep 17 00:00:00 2001 From: "Liam C." Date: Mon, 16 Mar 2026 06:38:59 +0100 Subject: [PATCH 2/2] docs: add OOM reproduction logs for earlyResolve circular dependency bug Captured from a real project (goalserve-poc demo-pwa) where APP_ENV=fallback($APP_ENV, $VARLOCK_ENV, "development") in .env.schema creates a self-referencing cycle that earlyResolve() follows infinitely. Log files: - varlock-oom-trace-1.log: OOM crash with 512MB Node heap cap - varlock-oom-trace-2.log: OOM crash with 256MB Node heap cap - varlock-env-info.log: Environment details (Node v22.22.1, varlock 0.4.1) - varlock-post-fix-trace.log: Clean error output after applying the fix Environment: Node v22.22.1, varlock 0.4.1, Linux x86_64, 8GB RAM Co-Authored-By: Claude Opus 4.6 (1M context) --- varlock-env-info.log | 36 ++++++++++++++++++++++++++++++++++++ varlock-oom-trace-1.log | 20 ++++++++++++++++++++ varlock-oom-trace-2.log | 20 ++++++++++++++++++++ varlock-post-fix-trace.log | 3 +++ 4 files changed, 79 insertions(+) create mode 100644 varlock-env-info.log create mode 100644 varlock-oom-trace-1.log create mode 100644 varlock-oom-trace-2.log create mode 100644 varlock-post-fix-trace.log diff --git a/varlock-env-info.log b/varlock-env-info.log new file mode 100644 index 00000000..ed1e0dbb --- /dev/null +++ b/varlock-env-info.log @@ -0,0 +1,36 @@ +=== Environment === +Date: Mon 16 Mar 2026 05:32:31 AM UTC +Node: v22.22.1 +npm: 10.9.4 +pnpm: 10.28.1 +OS: Linux 6.12.73+deb13-amd64 x86_64 +Memory: total used free shared buff/cache available +Mem: 7.8Gi 5.7Gi 1.0Gi 1.1Gi 2.4Gi 2.1Gi + +=== Varlock version === +0.4.1 + +=== Varlock package.json === +not found + +=== .env.schema head === +# @currentEnv=$APP_ENV +# @defaultSensitive=inferFromPrefix(VITE_) +# @defaultRequired=infer +# @generateTypes(lang=ts, path=./src/varlock-env.d.ts) +# --- + +# === Environment === + +# Runtime environment selector for the demo app. +# Falls back to varlock's auto-detected env (Vercel, CI, branch signals). +# @public @type=enum(development, staging, preview, production, test) +APP_ENV=fallback($APP_ENV, $VARLOCK_ENV, "development") + +# === Supabase === + +# Browser-safe Supabase project URL. +# @public @required @type=url +# @docs("Supabase JS client", https://supabase.com/docs/reference/javascript/initializing) +VITE_SUPABASE_URL=https://uwxumjrejpkwwzgvrjbv.supabase.co + diff --git a/varlock-oom-trace-1.log b/varlock-oom-trace-1.log new file mode 100644 index 00000000..fcf34df8 --- /dev/null +++ b/varlock-oom-trace-1.log @@ -0,0 +1,20 @@ + +<--- Last few GCs ---> + +[1040829:0x7b60000] 8214 ms: Scavenge 505.6 (519.1) -> 502.4 (519.6) MB, pooled: 0 MB, 3.17 / 0.00 ms (average mu = 0.260, current mu = 0.251) allocation failure; +[1040829:0x7b60000] 8600 ms: Mark-Compact (reduce) 504.4 (519.6) -> 502.9 (513.6) MB, pooled: 0 MB, 376.38 / 0.00 ms (+ 5.5 ms in 0 steps since start of marking, biggest step 0.0 ms, walltime since start of marking 386 ms) (average mu = 0.206, curren + +<--- JS stacktrace ---> + +FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory +----- Native stack trace ----- + + 1: 0xe42d60 node::OOMErrorHandler(char const*, v8::OOMDetails const&) [node] + 2: 0x121ddd0 v8::Utils::ReportOOMFailure(v8::internal::Isolate*, char const*, v8::OOMDetails const&) [node] + 3: 0x121e0a7 v8::internal::V8::FatalProcessOutOfMemory(v8::internal::Isolate*, char const*, v8::OOMDetails const&) [node] + 4: 0x144ba05 [node] + 5: 0x144ba33 [node] + 6: 0x1464b0a [node] + 7: 0x1467cd8 [node] + 8: 0x1ccd051 [node] +Aborted diff --git a/varlock-oom-trace-2.log b/varlock-oom-trace-2.log new file mode 100644 index 00000000..5f3d8a6e --- /dev/null +++ b/varlock-oom-trace-2.log @@ -0,0 +1,20 @@ + +<--- Last few GCs ---> + +[1041128:0x16156000] 4025 ms: Scavenge (reduce) (interleaved) 254.2 (263.4) -> 250.7 (256.6) MB, pooled: 0 MB, 5.17 / 0.00 ms (average mu = 0.204, current mu = 0.177) allocation failure; +[1041128:0x16156000] 4185 ms: Mark-Compact (reduce) 250.9 (256.6) -> 250.8 (256.9) MB, pooled: 0 MB, 159.91 / 0.00 ms (+ 1.7 ms in 0 steps since start of marking, biggest step 0.0 ms, walltime since start of marking 173 ms) (average mu = 0.192, curre + +<--- JS stacktrace ---> + +FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory +----- Native stack trace ----- + + 1: 0xe42d60 node::OOMErrorHandler(char const*, v8::OOMDetails const&) [node] + 2: 0x121ddd0 v8::Utils::ReportOOMFailure(v8::internal::Isolate*, char const*, v8::OOMDetails const&) [node] + 3: 0x121e0a7 v8::internal::V8::FatalProcessOutOfMemory(v8::internal::Isolate*, char const*, v8::OOMDetails const&) [node] + 4: 0x144ba05 [node] + 5: 0x144ba33 [node] + 6: 0x1464b0a [node] + 7: 0x1467cd8 [node] + 8: 0x1ccd051 [node] +Aborted diff --git a/varlock-post-fix-trace.log b/varlock-post-fix-trace.log new file mode 100644 index 00000000..fa1f34dd --- /dev/null +++ b/varlock-post-fix-trace.log @@ -0,0 +1,3 @@ +🚨 Error encountered while loading directory - /home/claude/code/goalserve-poc/apps/demo-pwa + +Circular dependency detected during early resolution: APP_ENV