Skip to content

fix(server): provide fresh ConfigProvider per HttpApi listener#25726

Merged
kitlangton merged 1 commit intodevfrom
kit/pty-no-auth-config-provider
May 4, 2026
Merged

fix(server): provide fresh ConfigProvider per HttpApi listener#25726
kitlangton merged 1 commit intodevfrom
kit/pty-no-auth-config-provider

Conversation

@kitlangton
Copy link
Copy Markdown
Contributor

TL;DR

Alternative to #25671. Same goal (fix the no-auth listen test on the HttpApi backend, unblock the Hono-deletion PR), smaller surface, no override of ServerAuth.Config.defaultLayer.

Root cause

Effect's ConfigProvider.fromEnv() (source) is not a live reader of process.env:

export function fromEnv(options?: { ... }): ConfigProvider {
  const env = options?.env ?? {
    ...globalThis?.process?.env,    // spread NOW, frozen
    ...(import.meta as any)?.env
  }
  const trie = buildEnvTrie(env)
  return make((path) => Effect.succeed(nodeAtEnv(trie, env, path)))
}

And the default ConfigProvider is cached on a module-singleton Context.Reference after the first read:

const getDefaultValue = (ref: Reference<any>) => {
  if (defaultValueCacheKey in ref) return ref[defaultValueCacheKey]
  return (ref as any)[defaultValueCacheKey] = ref.defaultValue()
}

So the first Config.string("OPENCODE_SERVER_PASSWORD") in the process:

  1. Falls back to the cached defaultValue factory.
  2. Factory calls fromEnv() once → snapshot of process.env at that moment.
  3. Result is stuck on the Reference for the remainder of the process.

Subsequent Server.listen() calls — even with their own fresh memoMap — still query the same cached, stale ConfigProvider. Mutating process.env after that first read is invisible to Effect Config.

I verified this empirically with a 4-line repro:

process.env.X = "first"
console.log(await Effect.runPromise(Config.string("X").asEffect()))   // "first"
process.env.X = "second"
console.log(await Effect.runPromise(Config.string("X").asEffect()))   // "first" (!!)
delete process.env.X
console.log(await Effect.runPromise(Config.string("X").asEffect().pipe(Effect.option)))  // Some("first") (!!!)

Fix

One line in listenHttpApi's buildLayer:

Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnv()))

Each Server.listen() evaluates fromEnv() fresh, snapshotting current process.env, and provides that ConfigProvider for the listener's layer build. The cached default is never consulted by the listener path.

ServerAuth.Config keeps its plain ConfigService.Service declaration. No defaultLayer override, no Layer.sync over Flag.*, no test-helper churn. The Config.string(...) field declarations stay meaningful.

Compared to #25671

Aspect #25671 (override defaultLayer) This PR (fresh provider per listener)
Lines changed ~115 ~25
Touches auth.ts yes (overrides defaultLayer, drops Effect Config wiring) no
Touches raw-route / UI tests yes (changes injection style) no
ConfigService abstraction escape-hatched for one service preserved
Per-request parity with Hono per-listener-build per-listener-build (same trade-off)
Production behavior change reads Flag.* instead of process.env reads process.env per listen() (was: once per process)

Equivalent outcome for the failing test, smaller blast radius. The architectural concern raised in #25671's "Concerns" section ("is overriding ConfigService.defaultLayer for one specific service the right shape") goes away — this PR doesn't override it.

Tests

  • httpapi-listen.test.ts: parameterized "tickets optional when auth disabled" over both backends — locks in effect-httpapi parity. 6/6 pass with this fix; 1/2 fails on dev (the effect-httpapi variant — confirmed empirically).
  • httpapi-raw-route-auth.test.ts and httpapi-ui.test.ts: unchanged. They already inject ConfigProvider.fromUnknown(...) per app() call, which works and continues to work.

Out of scope: pre-existing test pollution

While verifying this fix, I noticed the trio (session + provider + workspace) fails 16/17 on dev when run together — beforeEach/afterEach hook timeouts cascade. Same files pass alone. Confirmed pre-existing on dev (no fix applied). With this fix the count moves to 17/17 — one marginal extra fail from timing perturbation. Unrelated to ConfigProvider; likely the Server.Default() lazy + disposeAllInstances + DB teardown not draining cleanly when stacked. Worth a separate issue.

Effect's `ConfigProvider.fromEnv()` spreads `process.env` into a frozen
trie at construction time, and the default `ConfigProvider` is cached on
a module-singleton `Context.Reference` after the first read. Without an
explicit override, every later `Server.listen()` keeps observing the
env snapshot from the first config read in the process.

Install a fresh `ConfigProvider` inside `buildLayer` so each listener's
`Config.string(...)` resolves against the current `process.env`. This
keeps `ServerAuth.Config` (and any other env-backed config) consistent
with `Flag.*` reads in production and across the test suite.

Parameterize the no-auth listen test over both backends so the
effect-httpapi path is exercised; previously it was hardcoded to Hono.
@kitlangton kitlangton marked this pull request as ready for review May 4, 2026 17:06
@kitlangton kitlangton merged commit fb07c20 into dev May 4, 2026
16 of 18 checks passed
@kitlangton kitlangton deleted the kit/pty-no-auth-config-provider branch May 4, 2026 17:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant