Skip to content

Unhandled ObjectDisposedException on host shutdown when a dream cycle is scheduled or in flight #494

Description

@rockfordlhotka

Summary

When the host shuts down while DreamService has a cycle scheduled or in flight, an
ObjectDisposedException escapes as an unhandled exception rather than being caught and
logged. It happens on a normal SIGTERM (docker compose restart / up -d --force-recreate),
not just on abnormal termination.

Observed on roughly eight consecutive container restarts today, i.e. every restart where a
dream cycle was scheduled.

Unhandled exception. System.ObjectDisposedException: The CancellationTokenSource has been disposed.

On one occasion it was accompanied by:

System.Threading.Tasks.TaskCanceledException: A task was canceled.
   at Microsoft.Extensions.Hosting.Internal.Host.ForeachService[T](IEnumerable`1 services, CancellationToken token, ...)

Impact is limited — the process is exiting anyway — but it makes clean shutdown indistinguishable
from a crash in logs and monitoring, and it obscures any real shutdown fault that occurs alongside it.

Likely mechanism

Two ordering hazards, both plausible sources. I have not isolated which one fires (or whether both do).

1. Slot acquisition happens outside DreamService.DreamAsync's try.

AgentWorkSerializer.Dispose() disposes _preemptCts. TryAcquireForScheduledAsync then reads
_preemptCts.Token, which throws ObjectDisposedException once disposal has happened:

// AgentWorkSerializer.cs
lock (_preemptLock)
{
    preemptToken = _preemptCts.Token;   // throws if _preemptCts already disposed
}

DreamAsync calls this before its try block opens, so the exception is not covered by the
existing catch (OperationCanceledException) / catch (Exception) handlers:

// DreamService.cs — slot acquired outside the try
var slot = await _workSerializer.TryAcquireForScheduledAsync(CancellationToken.None);
if (slot is null) { ... return; }

LoadDirectives(initialLoad: false);
_logger.LogInformation("DreamService: dream cycle starting");

try   // <- everything below is protected; the acquisition above is not
{
    ...
}
catch (Exception ex) { _logger.LogError(ex, "DreamService: dream cycle failed"); }
finally { await slot.DisposeAsync(); }

2. ArmNextCronTimer can touch an already-disposed Timer.

OnTimerTickAsync calls ArmNextCronTimer() after DreamAsync() returns, which calls
_timer?.Change(...). If DreamService.Dispose() has already run, Change throws on the disposed
timer.

Why shutdown does not prevent either. StopAsync only disarms the timer; it does not wait for a
callback that is already running:

public Task StopAsync(CancellationToken cancellationToken)
{
    _timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
    return Task.CompletedTask;
}

public void Dispose() => _timer?.Dispose();

So an in-flight cycle can outlive StopAsync and reach for objects the DI container has since
disposed. The timer callback is also fire-and-forget, so nothing observes the resulting task:

_timer = new Timer(
    state => { _ = OnTimerTickAsync(); },   // task discarded
    null,
    _options.InitialDelay,
    Timeout.InfiniteTimeSpan);

Reproduction

  1. Configure a dream schedule so a cycle is due soon — e.g. Dream__CronSchedule=*/5 * * * *
    with Dream__InitialDelay=00:01:00.
  2. Start the agent and let it register the timer.
  3. docker compose restart agent (or up -d --force-recreate agent) around the time a cycle
    is due, or while one is running.
  4. The unhandled ObjectDisposedException appears in the container log during teardown.

A short InitialDelay plus a frequent cron makes this hit essentially every restart.

Suggested fixes

Any of these would remove the unhandled exception; the first two look like the substantive ones.

  • Make shutdown wait for an in-flight cycle. Hold a CancellationTokenSource for the service,
    cancel it in StopAsync, and await the running cycle (or use Timer.DisposeAsync(), which
    completes after callbacks finish) so the cycle cannot outlive the objects it depends on.
  • Bring slot acquisition inside the guarded region in DreamAsync, so a disposed serializer
    is logged as a failed cycle rather than escaping. A disposed AgentWorkSerializer could also
    return null from TryAcquireForScheduledAsync — semantically "no slot available", which
    callers already handle — instead of throwing.
  • Observe the timer task rather than discarding it, so anything thrown from OnTimerTickAsync
    is logged instead of becoming an unobserved/unhandled exception.
  • Guard ArmNextCronTimer against a disposed timer (catch ObjectDisposedException, or check
    a _stopping flag set by StopAsync).

Environment

  • .NET 10, RockBot.Host.DreamService / RockBot.Host.AgentWorkSerializer
  • Reproduced in the deploy/docker-compose stack

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions