Skip to content

[Bug] An actor that throws in onReceive is restarted with no log output at any level, so a crash-looping actor is invisible in production #916

Description

@pathosDev

Problem

An actor that throws in onReceive and is restarted by its supervisor produces zero log output at any level. Not a warning, not a debug line — nothing. A crash-looping actor in production is invisible: the only residual signals are actor_restarted_total (unlabelled, in a registry that is a no-op unless someone called .enable() in code) and an ActorRestarted event on a stream that nothing in src/ subscribes to.

ActorCell.ts contains six log calls across 1272 lines, and an ordinary actor's failure path passes through none of them.

Evidence

The catch hands the cause to the parent and logs nothing:

src/internal/ActorCell.ts:1030-1037
      } catch (e) {
        const err = e instanceof Error ? e : new Error(String(e));
        failure = err;
        if (span) {
          span.recordException(err);
          span.setStatus('error', err.message);
        }
        this.failToParent(err, message);

failToParent logs only when there is no parent — i.e. only for the root guardian:

src/internal/ActorCell.ts:1107-1119
  private failToParent(cause: Error, message?: unknown): void {
    this.mailbox.suspend();
    if (this.state === 'running') this.state = 'suspended';
    for (const child of this._children.values()) child.enqueueSystem({ kind: 'suspend' });

    if (this._parent) {
      this._parent.enqueueSystem({ kind: 'failure', cause, child: this.self, message });
    } else {
      // Root guardian failed — log and terminate the system.
      this.log.error(`Guardian ${this.path} failed; terminating system`, cause);

onFailure logs only once the restart threshold is exceeded (:1154), and onRecreate (:914) logs nothing. The complete set of log calls in the file is lines 622, 810, 874, 932, 1116, 1154.

Reproduced with a logger injected via ActorSystemOptions at LogLevel.Trace, for three crashes and three restarts:

log lines mentioning the crash: []
total log lines captured: 0

Proposal

Log the failure where it happens. One this.log.error in failToParent carrying the cause, its stack, the actor path and the failing message's type, plus one line in onRecreate naming the directive taken. That is roughly five lines and it converts an invisible crash loop into an alertable one.

Rate-limiting is worth considering for a hot loop, but silence is not the right default — the current behaviour means the first thing an operator learns about a failing actor is a downstream symptom.

Acceptance sketch

Verification status

Found in the ten-lens production-readiness review of 2026-08-05 (v0.13.0) and re-verified before filing: reproduced by execution with a spy logger at trace level; the log-call inventory above is a complete grep of this.log.* in ActorCell.ts.

Part of the production-readiness review batch — tracked in #913.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority: highTop priority — high impact, plan nextproduction-goalBlocks or defines the path to production readiness

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions