Problem
When a service constructor (the effect passed to Effect.Service / Layer.effect / Layer.scoped) spawns a background fiber with plain Effect.fork, the fiber is supervised by the short-lived layer-construction fiber rather than the service's scope. The rule flags Effect.fork inside a layer/service constructor effect and suggests Effect.forkScoped (plus a scoped layer) so the fiber's lifetime is attached to the lifetime of the service.
Why the compiler is silent / what breaks at runtime: Compiles cleanly, but the background fiber's lifetime is wrong at runtime: auto-supervision can interrupt it as soon as the constructor fiber finishes building the layer (the poll loop silently never runs), or in other wirings the fiber leaks past the service's shutdown instead of being interrupted when the scope closes.
Both examples below type-check with zero errors against effect@4.0.0-beta.104 (re-verified with an isolated per-proposal strict tsconfig) — so the compiler offers no protection here and a diagnostic is the only static safety net.
Bad — compiles cleanly, the rule should flag this
// RULE: fork-in-layer-constructor-not-scoped
// BAD: the service constructor spawns its background poll loop with plain
// Effect.forkChild (v3: Effect.fork). The forked fiber is auto-supervised by
// the short-lived layer-construction fiber, NOT by the layer's scope: once the
// constructor fiber finishes building the layer the poll loop can be
// interrupted immediately (it silently never runs), or in other wirings it
// outlives the service instead of being interrupted when the scope closes.
// The linter should flag Effect.forkChild inside a layer/service constructor
// effect and suggest Effect.forkScoped.
import { Context, Effect, Layer, Queue } from "effect"
interface JobsShape {
readonly enqueue: (job: string) => Effect.Effect<boolean>
}
export class Jobs extends Context.Service<Jobs, JobsShape>()("Jobs") {}
export const JobsLive = Layer.effect(
Jobs,
Effect.gen(function*() {
const queue = yield* Queue.unbounded<string>()
const pollLoop = Effect.gen(function*() {
const job = yield* Queue.take(queue)
yield* Effect.log(`processing ${job}`)
})
// parent is the layer-build fiber, not the layer's scope
yield* Effect.forkChild(Effect.forever(pollLoop))
return { enqueue: (job: string) => Queue.offer(queue, job) }
})
)
export const program = Effect.gen(function*() {
const jobs = yield* Jobs
yield* jobs.enqueue("send-welcome-email")
}).pipe(Effect.provide(JobsLive))
void program
Good
// RULE: fork-in-layer-constructor-not-scoped
// GOOD: the background poll loop is forked with Effect.forkScoped, so the
// fiber is attached to the layer's scope instead of the transient
// layer-construction fiber. Layer.effect runs the constructor inside the
// layer's scope (its R excludes Scope), so the poll loop starts when the
// service is built and is interrupted exactly when the service's scope closes.
import { Context, Effect, Layer, Queue } from "effect"
interface JobsShape {
readonly enqueue: (job: string) => Effect.Effect<boolean>
}
export class Jobs extends Context.Service<Jobs, JobsShape>()("Jobs") {}
export const JobsLive = Layer.effect(
Jobs,
Effect.gen(function*() {
const queue = yield* Queue.unbounded<string>()
const pollLoop = Effect.gen(function*() {
const job = yield* Queue.take(queue)
yield* Effect.log(`processing ${job}`)
})
// tied to the layer's scope: lives as long as the service does
yield* Effect.forkScoped(Effect.forever(pollLoop))
return { enqueue: (job: string) => Queue.offer(queue, job) }
})
)
export const program = Effect.gen(function*() {
const jobs = yield* Jobs
yield* jobs.enqueue("send-welcome-email")
}).pipe(Effect.provide(JobsLive))
void program
Where this came up
Mined from the Effect Office Hours playlist; deduplicated against all implemented tsgo diagnostics and prior rule-proposal issues.
Proposed rule name
forkInLayerConstructorNotScoped
Problem
When a service constructor (the effect passed to Effect.Service / Layer.effect / Layer.scoped) spawns a background fiber with plain Effect.fork, the fiber is supervised by the short-lived layer-construction fiber rather than the service's scope. The rule flags Effect.fork inside a layer/service constructor effect and suggests Effect.forkScoped (plus a scoped layer) so the fiber's lifetime is attached to the lifetime of the service.
Why the compiler is silent / what breaks at runtime: Compiles cleanly, but the background fiber's lifetime is wrong at runtime: auto-supervision can interrupt it as soon as the constructor fiber finishes building the layer (the poll loop silently never runs), or in other wirings the fiber leaks past the service's shutdown instead of being interrupted when the scope closes.
Both examples below type-check with zero errors against
effect@4.0.0-beta.104(re-verified with an isolated per-proposal strict tsconfig) — so the compiler offers no protection here and a diagnostic is the only static safety net.Bad — compiles cleanly, the rule should flag this
Good
Where this came up
Mined from the Effect Office Hours playlist; deduplicated against all implemented tsgo diagnostics and prior rule-proposal issues.
Proposed rule name
forkInLayerConstructorNotScoped