Relationships
#1604 Daemon telemetry flush: stop() can skip its final flush when a timer drain is already in flight
Opened by keeb · 8/11/2026· Shipped 8/11/2026
Summary
DaemonTelemetryFlushService.stop() performs a final flush so a draining swamp serve does not strand the runs it just finished. That final flush can silently do nothing: if a timer tick's #drain() is already running when stop() is called, the #running guard makes stop()'s drain an immediate no-op, stop() resolves, and shutdown proceeds.
Found by the adversarial review on swamp-club/swamp#2117 (shipped in v20260811.162323.0-sha.e1a01920). Not a regression — it arrived with the flush loop in that PR.
Detail
src/serve/telemetry_flush.ts:
async #drain(): Promise<void> {
if (this.#running) return; // <- makes stop()'s drain a no-op
...
}
async stop(): Promise<void> {
// clear timer, set #stopped
await this.#drain(); // <- may return immediately
}Ticks are started as void this.#tick(), so nothing holds a reference to the in-flight drain and stop() cannot await it.
Concrete scenario
- T=0 — timer fires,
#drain()starts and callsfindUnflushed. - T=0.5s — a workflow run completes and writes its telemetry entries.
- T=1s — shutdown begins;
stop()is called.#runningis still true, so its drain returns immediately. stop()resolves;serve.tsproceeds toac.abort()and the CLI teardown.
The entries from step 2 were never in the batch the in-flight drain read at T=0.
Why it is not worse than it sounds
- The in-flight drain loops until the spool is empty, so it often picks the new entries up on its next batch.
- The CLI teardown flush is a second chance, though its
AbortSignal.timeout(2000)starts at creation and can expire while serialized behind the in-flight drain on the shared#flushChain. insert_id(added in the same PR) makes it harmless if both paths send the same entry.serve.tsdrains active runs before callingstop(), so the window is narrow.
Worst case is not data loss — unflushed entries stay on disk and go out on the next swamp invocation. But a daemon that is stopped and not restarted holds them indefinitely, which is the shape of the problem #1591 existed to fix.
Suggested fix
Track the in-flight drain so stop() can await it before its own:
#drainPromise: Promise<void> | null = null;
async stop(): Promise<void> {
// ...clear timer, set #stopped...
if (this.#drainPromise) await this.#drainPromise;
await this.#drain();
}A test would drive a tick, call stop() while that drain is still resolving, and assert that entries written in between are sent.
Also in the same area
src/domain/telemetry/telemetry_service.ts — the forkForRun docstring says flushing is serialized by a per-instance mutex {@link #flushInFlight}, but the field is #flushChain. Stale reference left by a rename during development, sitting in the comment that documents the concurrency invariant. Worth fixing alongside.
Environment
- swamp v20260811.162323.0-sha.e1a01920 and later
- Only affects
swamp serve; the interactive CLI does not run the flush loop
Related
- #1591 — the serve telemetry gap this loop was built for
- #1603 — insert_id idempotency, which bounds the blast radius here
Shipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.