Skip to content

Rule proposal: Effect.callback body that never calls resume hangs the fiber forever #483

Description

@mattiamanzati

Problem

Inside Effect.callback((resume) => { ... }), flags bodies where the resume parameter is never invoked (or never referenced) on any code path, or is only invoked in an error branch while the success branch never resumes. The types are satisfied regardless of whether resume is called, but the resulting effect can only complete via resume.

Why the compiler is silent / what breaks at runtime: The effect type-checks with any success/error type you claim, but a callback that never calls resume produces a fiber that suspends forever — the program silently hangs until externally interrupted.

Both examples below type-check with zero errors against effect@4.0.0-beta.104 (re-verified with an isolated 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: effect-callback-never-resumes
// BAD: the Effect.callback bodies below type-check for any claimed success/error
// type, but the fiber can only ever complete via `resume`. Variant 1 never
// references resume at all; variant 2 resumes only on the error path, so a
// successful `listening` event leaves the fiber suspended forever. Both hang
// silently until externally interrupted.
import * as net from "node:net"
import { Data, Effect } from "effect"

class ListenError extends Data.TaggedError("ListenError")<{
  readonly cause: unknown
}> {}

// Variant 1: `resume` is never referenced on any code path. The server starts
// listening, but nothing ever completes the effect.
export const listenForever = (port: number): Effect.Effect<net.AddressInfo, ListenError> =>
  Effect.callback<net.AddressInfo, ListenError>((_resume) => {
    const server = net.createServer()
    server.listen(port)
    // forgot to wire 'listening'/'error' events to resume
  })

// Variant 2: `resume` is invoked only in the error branch. If the server binds
// successfully, the 'listening' event fires but the fiber never resumes.
export const listenErrorsOnly = (port: number): Effect.Effect<net.AddressInfo, ListenError> =>
  Effect.callback<net.AddressInfo, ListenError>((resume) => {
    const server = net.createServer()
    server.once("error", (cause) => {
      resume(Effect.fail(new ListenError({ cause })))
    })
    server.listen(port)
    // success branch missing: 'listening' never calls resume(Effect.succeed(...))
  })

const program = Effect.flatMap(listenForever(0), (address) => Effect.log(address.port))
void program

Good

// RULE: effect-callback-never-resumes
// GOOD: every terminal outcome of the async operation is wired to `resume`:
// the 'error' event fails the effect and the 'listening' event succeeds it,
// so the fiber always completes. The returned cleanup effect tears the server
// down if the fiber is interrupted while still suspended.
import * as net from "node:net"
import { Data, Effect } from "effect"

class ListenError extends Data.TaggedError("ListenError")<{
  readonly cause: unknown
}> {}

export const listen = (port: number): Effect.Effect<net.AddressInfo, ListenError> =>
  Effect.callback<net.AddressInfo, ListenError>((resume) => {
    const server = net.createServer()
    server.once("error", (cause) => {
      resume(Effect.fail(new ListenError({ cause })))
    })
    server.once("listening", () => {
      const address = server.address()
      if (address === null || typeof address === "string") {
        server.close()
        resume(Effect.fail(new ListenError({ cause: "no address info" })))
      } else {
        resume(Effect.succeed(address))
      }
    })
    server.listen(port)
    // interruption cleanup: close the server if nobody resumed yet
    return Effect.sync(() => {
      server.close()
    })
  })

const program = Effect.flatMap(listen(0), (address) => Effect.log(address.port))
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

effectCallbackNeverResumes

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