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
- Configure a dream schedule so a cycle is due soon — e.g.
Dream__CronSchedule=*/5 * * * *
with Dream__InitialDelay=00:01:00.
- Start the agent and let it register the timer.
docker compose restart agent (or up -d --force-recreate agent) around the time a cycle
is due, or while one is running.
- 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
Summary
When the host shuts down while
DreamServicehas a cycle scheduled or in flight, anObjectDisposedExceptionescapes as an unhandled exception rather than being caught andlogged. 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.
On one occasion it was accompanied by:
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'stry.AgentWorkSerializer.Dispose()disposes_preemptCts.TryAcquireForScheduledAsyncthen reads_preemptCts.Token, which throwsObjectDisposedExceptiononce disposal has happened:DreamAsynccalls this before itstryblock opens, so the exception is not covered by theexisting
catch (OperationCanceledException)/catch (Exception)handlers:2.
ArmNextCronTimercan touch an already-disposedTimer.OnTimerTickAsynccallsArmNextCronTimer()afterDreamAsync()returns, which calls_timer?.Change(...). IfDreamService.Dispose()has already run,Changethrows on the disposedtimer.
Why shutdown does not prevent either.
StopAsynconly disarms the timer; it does not wait for acallback that is already running:
So an in-flight cycle can outlive
StopAsyncand reach for objects the DI container has sincedisposed. The timer callback is also fire-and-forget, so nothing observes the resulting task:
Reproduction
Dream__CronSchedule=*/5 * * * *with
Dream__InitialDelay=00:01:00.docker compose restart agent(orup -d --force-recreate agent) around the time a cycleis due, or while one is running.
ObjectDisposedExceptionappears in the container log during teardown.A short
InitialDelayplus 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.
CancellationTokenSourcefor the service,cancel it in
StopAsync, and await the running cycle (or useTimer.DisposeAsync(), whichcompletes after callbacks finish) so the cycle cannot outlive the objects it depends on.
DreamAsync, so a disposed serializeris logged as a failed cycle rather than escaping. A disposed
AgentWorkSerializercould alsoreturn
nullfromTryAcquireForScheduledAsync— semantically "no slot available", whichcallers already handle — instead of throwing.
OnTimerTickAsyncis logged instead of becoming an unobserved/unhandled exception.
ArmNextCronTimeragainst a disposed timer (catchObjectDisposedException, or checka
_stoppingflag set byStopAsync).Environment
RockBot.Host.DreamService/RockBot.Host.AgentWorkSerializerdeploy/docker-composestack