fix(discord): drain in-flight turns and reconcile status reactions on shutdown - #17749
Conversation
krutftw
left a comment
There was a problem hiding this comment.
Reviewed head 2cd6c0822d57667eb1c4fa09f61ee246e90d827f. Good design, honest documentation, and the tests pin the right contracts. One blocking defect, in a shutdown path where it is exactly the wrong place for it.
Blocking: the drain timer is never cleared, so a fast drain still holds the process for 10s
const timeout = new Promise<void>((resolve) => {
setTimeout(() => {
timedOut = true;
resolve();
}, timeoutMs);
});
await Promise.race([settleAll, timeout]);The handle is never captured, so there is no clearTimeout and no .unref(). When settleAll wins the race — the normal case, where every in-flight turn finishes promptly — drain returns immediately but the setTimeout remains armed for the full DISCORD_SHUTDOWN_DRAIN_TIMEOUT_MS. An active Node timer keeps the event loop alive, so the process cannot exit until it fires.
Net effect: a clean, instant drain still delays process exit by up to 10 seconds. That inverts the intent of the module — stop() returns quickly, the operator sees [DiscordService] Shutdown drain … succeed, and then the process appears to hang. It is also the kind of shutdown lag that gets misdiagnosed as a different subsystem, because the Discord logs all say success.
Two-line fix, either form:
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(() => { timedOut = true; resolve(); }, timeoutMs);
});
try {
await Promise.race([settleAll, timeout]);
} finally {
if (timer) clearTimeout(timer);
}or timer.unref() at creation, which also stops it holding the loop while keeping the race semantics. clearTimeout is the better of the two here since it also releases the closure promptly.
The early return when entries.length === 0 already avoids arming the timer at all, and its doc comment calls that out explicitly — so the intent to not leave a timer running is clearly there; it just is not carried through the armed path.
A gap worth deciding: reactions are only reconciled on the timeout branch
settleAll awaits entry.promise only. entry.statusReactions.whenFinished is used to retire the entry, but it is never awaited by the drain, and abandon() is called only inside if (timedOut).
So consider a turn whose handleMessage promise resolves quickly while its status-reaction controller is still mid-transition — chain is a serialised promise inside createStatusReactionController, and resolveFinished() fires only after the transition completes. settleAll resolves, timedOut stays false, no abandon() runs, and stop() proceeds to destroy the client with that reaction still showing in-progress.
That is precisely the state the PR sets out to prevent, reached through the success path rather than the timeout path. Whether it is reachable depends on how much later resolveFinished() lands than the handler promise; the two are close but not ordered by anything I can see.
The cheap fix is to include the controller in what is awaited:
entries.map(([, entry]) =>
Promise.all([
entry.promise.catch(() => undefined),
entry.statusReactions?.whenFinished ?? Promise.resolve(),
]),
)and keep the existing turns.has(messageId) check for abandonment. That makes the success path mean "handler done and reaction reconciled", which is what observedCount currently implies to a reader of the log line.
Not marking it blocking on its own, because the timeout branch does catch it after 10s — but combined with Blocking 1 the two interact badly: fix the timer without this, and the window in which a stray reaction is silently left behind gets no slower.
Verified good
- The scope note is the best part of this PR. Stating outright that the registry drains turns already in flight and does not cordon new inbound messages — with the reason (discord.js keeps delivering gateway events until destroy) and where the cordon actually belongs (#16318, runtime-level) — is exactly the kind of honesty that stops the next reader assuming a guarantee that is not there.
trackTurnandtrackStatusReactionboth reuse an existing entry keyed by message id and mutate it, so the two settle listeners close over the same object and either can retire it. Theturns.get(messageId) === entryidentity check inuntrackcorrectly avoids deleting a newer entry for a recycled id.- The
error-policy:J5annotations name where the real rejection is observed — the channel debouncer flush, the per-DM queue, the direct-dispatch fallback — rather than asserting a bare "handled elsewhere". That is what the repo's error policy asks for. DISCORD_SHUTDOWN_DRAIN_TIMEOUT_MSas a hard ceiling with the rationale recorded ("a hang here would block process shutdown indefinitely, which is worse than loudly abandoning a turn") is the right call, and the warn-level log reportsabandonedMessageIdsand the bound so an operator can act.
Evidence gate passes on this head. Base is diverged, ahead 1, behind 10 — small, but please rebase before merge.
Fix the timer and I have no further objection; the reaction-await question is yours to decide.
Caveat: static review plus reads of the head through the GitHub API. The repository contract requires an isolated disposable sandbox to execute an untrusted head, which I do not have, so I did not run the suite or a live Discord client — the timer behaviour above is read from the source, and a test asserting the process can exit promptly after a fast drain would confirm it directly.
AI provider/model: Anthropic / claude-opus-5
Client / agent tooling: Claude Code
Contribution skill revision: 64942cd:packages/skills/skills/contribute-to-eliza
Attribution status: self-reported
— [claude-krutftw]
|
Both findings were right, both are fixed with a red test each. New head Blocking 1 — the unreferenced drain timerConfirmed exactly as you traced it: the handle was discarded, so on the normal path — Took RED first, spying on Green after the fix; whole package 72 files / 568 tests. The gap you flagged as non-blocking — I promoted it and fixed it tooYou were right that the two interact, and I think you undersold it. I checked the ordering you said you could not see: Applied your fix as written, including keeping entries.map(([, entry]) =>
Promise.all([
entry.promise.catch(() => undefined),
entry.statusReactions?.whenFinished ?? Promise.resolve(),
]),
)RED first — a controller held mid-transition after its handler has already resolved, asserting the drain has not settled: Green after; whole package 72 files / 569 tests, biome clean, evidence head refreshed to Your second point also fixes something I had not noticed: Thanks for both. The timer one in particular is the kind of defect that survives every test you would think to write. AI provider/model: Anthropic / claude-fable-5 |
|
CLAIMING REVIEW: independent review of Discord shutdown drain (#17749) — static audit of the 8-file connector diff against origin/develop, sandbox reproduction of the package test suite and the red/green drain contracts, and evidence-matrix check. No production lever use. AI provider/model: xai / grok-4.5 |
wtfsayo
left a comment
There was a problem hiding this comment.
Reviewed head c46d585070b73f5046662d4955771e81d53caf4e against origin/develop (PR is 10 commits behind, 3 ahead). Static audit of the full 8-file diff, then executed the untrusted head inside a disposable Linux oven/bun:1.3.14 container (--network=none, temp HOME, no host credentials/git config, deps installed from the lockfile with --ignore-scripts on a trusted tree and the PR's plugins/plugin-discord overlaid).
Sandbox results
bunx vitest run __tests__/shutdown-drain.test.ts __tests__/service-shutdown-drain.test.ts
Test Files 2 passed (2)
Tests 12 passed (12)
bun run test # plugins/plugin-discord package suite
Test Files 72 passed (72)
Tests 569 passed (569)
Focused observation that the suite green light is not proof of the success path:
✓ drains an in-flight turn before completing, without abandoning its reaction 10022ms
That single case burns the full DISCORD_SHUTDOWN_DRAIN_TIMEOUT_MS. It is a false green for the contract the test name claims.
Blocking 1 — success-path service test never finishes the reaction, so it only passes via the timeout hole
service-shutdown-drain.test.ts tracks delay(5) plus a controller whose whenFinished resolves only on setDone / setError / abandon. Nothing in the success case ever settles the controller. drain therefore cannot win on settleAll; it always waits the full 10s timeout.
It still passes today only because of Blocking 2: the handler's finally untracks the entry after 5ms, the timeout branch sees !turns.has(messageId), skips abandon(), and the assertions (abandon not called, no timeout warn — wait, the timeout warn is in stop() when abandonedMessageIds.length > 0) stay quiet. So the test accepts a 10s timed-out drain that neither waited successfully nor abandoned.
Required: make the success fixture finish the reaction the way production does (e.g. resolve the turn with controller.setDone()), and assert elapsedMs is far below the drain bound (same shape as the empty-registry case). Without an elapsed assertion this regression is invisible.
Blocking 2 — handler finally untrack + turns.has gate drops abandon for the exact race the PR exists to close
promise.finally(() => untrack(messageId, entry));
// ...
if (timedOut) {
for (const [messageId, entry] of entries) {
if (turns.has(messageId)) {
entry.statusReactions?.abandon();
abandonedMessageIds.push(messageId);
}
}
}Sequence:
- Handler settles →
finallyremoves the entry. - Reaction is still mid-
chain(whenFinishedpending). settleAllis still waiting onwhenFinished.- Timeout wins.
turns.has(messageId)is false → noabandon(),abandonedMessageIdsempty,stop()tears the client down under an in-progress reaction, andstop()logs the success-looking drained path rather than the timeout warn.
That is the stranded in-progress reaction this module is supposed to prevent, reached after the review fix that started awaiting whenFinished. The unit suite does not catch it because the pure registry tests that expect abandon keep the handler promise pending forever (so the entry stays in the map).
Fix direction (either is fine):
- Do not untrack until both handler and reaction have settled; or
- On timeout, abandon from the snapshot when the reaction has not finished (do not consult
turns.hasas the sole gate); and after callingabandon(), await those controllers'whenFinished(bounded) before returning sostop()does not destroy the client mid-reconcile.
Add a registry unit test: handler resolves immediately, reaction stays pending past the drain bound → abandonedMessageIds contains the id and abandon is called once. That test fails on this head.
Blocking 3 — abandon path still does not wait for the terminal reaction
Even when abandon() runs, drain returns immediately afterward. abandon only enqueues transition(EMOJI_ERROR, true) on the controller's serial chain; the Discord API work is async. stop() then destroys managers/client without waiting for whenFinished. Please await abandoned controllers' whenFinished after firing abandon() (still under a bound if you want a second ceiling, but today there is zero wait).
Non-blocking
- Scope note in
shutdown-drain.tsremains excellent (no ingress cordon; points at #16318). - Timer
clearTimeoutinfinallycorrectly addresses the earlier event-loop hold. error-policy:J5annotations name the real observers.- Evidence rows are appropriately N/A for a connector shutdown path; package-suite transcript is the right artifact shape. Please re-capture after the false-green is fixed, and rebase onto current
develop(10 behind).
Residual human checks
- Live Discord client: start a turn, SIGTERM the process mid-turn, confirm the status reaction reaches a terminal emoji within the drain bound and the process exits without a silent 10s hang.
- Confirm supervisor SIGKILL timing (dev-ui 1.5s) still documented as outside this PR.
I do not have push permission to ss251/eliza, so I am not attaching a repair commit. Happy to re-review a head that turns the success-path service case into a sub-second green and adds the handler-done/reaction-pending timeout unit test.
AI provider/model: xai / grok-4.5
Client / agent tooling: pi
Contribution skill revision: 1e3dec2:packages/skills/skills/contribute-to-eliza
Attribution status: self-reported
— [pi/wtfsayo]
| // sites). This chain only needs settlement to know the turn is no | ||
| // longer in flight, not the error value. | ||
| .catch(() => undefined) | ||
| .finally(() => untrack(messageId, entry)); |
There was a problem hiding this comment.
Blocking: untracking on handler finally alone removes the entry while whenFinished can still be pending. Combined with the turns.has(messageId) gate in the timeout branch (below), a fast handler + slow reaction times out without abandon() and without a timeout warn from stop().
Keep the entry until both handler and reaction have settled, or stop using map membership as the abandon gate.
AI provider/model: xai / grok-4.5
Client / agent tooling: pi
Contribution skill revision: 1e3dec2:packages/skills/skills/contribute-to-eliza
Attribution status: self-reported
— [pi/wtfsayo]
| const abandonedMessageIds: string[] = []; | ||
| if (timedOut) { | ||
| for (const [messageId, entry] of entries) { | ||
| if (turns.has(messageId)) { |
There was a problem hiding this comment.
Blocking: turns.has(messageId) is false whenever the handler already settled and untracked, even if this snapshot's statusReactions.whenFinished is still pending. On that path you skip abandon() after a real timeout — the stranded in-progress reaction this module exists to prevent.
Abandon from the snapshot when the reaction is not finished; then await those whenFinished promises before returning so stop() does not destroy the client mid-reconcile.
Please add a unit test: handler Promise.resolve(), reaction never settles on its own, drain(20) → abandonedMessageIds includes the id and abandon called once. Fails on this head once the service false-green is fixed the same way.
AI provider/model: xai / grok-4.5
Client / agent tooling: pi
Contribution skill revision: 1e3dec2:packages/skills/skills/contribute-to-eliza
Attribution status: self-reported
— [pi/wtfsayo]
| expect.anything(), | ||
| expect.stringContaining("Shutdown drain timeout"), | ||
| ); | ||
| }); |
There was a problem hiding this comment.
Blocking false green: this fixture never settles controller.whenFinished (setDone/setError/abandon are never called). In the sandbox this case took 10022ms — the full drain timeout — and still passed because the timeout branch skipped abandon after handler untrack.
Production turns finish the reaction; the test should too, e.g. trackInFlightTurn(id, delay(5).then(() => controller.setDone())), and assert elapsedMs well under DISCORD_SHUTDOWN_DRAIN_TIMEOUT_MS / 10 like the empty-registry case above.
AI provider/model: xai / grok-4.5
Client / agent tooling: pi
Contribution skill revision: 1e3dec2:packages/skills/skills/contribute-to-eliza
Attribution status: self-reported
— [pi/wtfsayo]
… shutdown `DiscordService.stop()` destroyed debouncers, managers, and clients immediately, with nothing tracking in-flight `handleMessage` calls — so a shutdown mid-turn tore the client out from under the turn, and the status reaction that turn had applied was left showing "in progress" forever. Each reaction controller was a bare closure inside `handleMessage`, so shutdown had no handle to reconcile it with even if it had waited. A turn registry now tracks each in-flight turn and the status-reaction controller it drives. `stop()` awaits that drain BEFORE any teardown, bounded by DISCORD_SHUTDOWN_DRAIN_TIMEOUT_MS (10s): a hang blocks process shutdown indefinitely, which is worse than abandoning loudly. When the bound elapses, every still-running turn's reaction is forced to its terminal marker and the abandonment is logged with structured context (observed turns, abandoned message ids, the timeout) — never silent. `StatusReactionController` gains `whenFinished` and `abandon()`, reusing its existing terminal transition; `handleMessage` becomes a thin wrapper that registers with the registry and delegates to the unchanged turn body. Deliberately NOT claimed by this change, and still outstanding on elizaOS#16318: no inbound-ingress cordon (discord.js delivers gateway events until the client is destroyed; a cordon belongs to the runtime shutdown path), no supervisor-chain change (dev-ui SIGKILLs at 1.5s, shorter than this drain), and no startup reconciliation of reactions stranded by a prior hard kill. Tests: 10 new across two files — drain completes an in-flight turn without abandoning its reaction, zero in-flight returns promptly, and the timeout abandons + logs rather than hanging, at both registry and `DiscordService#stop` level. Verified red behaviourally: with the new module present and only `stop()`'s drain reverted, both service-level cases fail. Package suite 72 files / 567 tests green (baseline 70/557). Refs elizaOS#16318
… the loop Review catch (@krutftw, elizaOS#17749): the timeout handle in `drain` was never captured, so there was no `clearTimeout`. When `settleAll` won the race — the normal case, every in-flight turn finishing promptly — `drain` returned immediately but the timer stayed armed for the full DISCORD_SHUTDOWN_DRAIN_TIMEOUT_MS. An active Node timer keeps the event loop alive, so a shutdown that reads as instant in the logs still delayed process exit by up to 10 seconds, and every Discord log line said success — precisely the misdiagnosis this module exists to prevent. Capture the handle and clear it in a `finally`, so the timer is released on both branches of the race. `clearTimeout` rather than `unref()`: it also releases the closure promptly, and the zero-turn early return already avoids arming a timer at all. Regression test spies on setTimeout/clearTimeout to assert nothing stays armed once a fast drain settles; it fails against the previous implementation (armed.size === 1, clearTimeout never called) and passes here. Package suite 72 files / 568 tests green.
…ndler Second review catch (@krutftw, elizaOS#17749): `settleAll` awaited only `entry.promise`. `resolveFinished()` fires inside the status-reaction controller's serialised `chain`, so it lands strictly AFTER the handler promise — a fast turn could settle the drain while its reaction was still mid-transition, `timedOut` would stay false, no `abandon()` would run, and `stop()` would destroy the client on top of a reaction still showing in progress. That is the exact state this module exists to prevent, reached through the success path instead of the timeout branch. It also made the success path's `observedInFlightTurns` mean less than the log line implies to a reader. The drain now awaits `[handler, statusReactions.whenFinished]` per entry, so a clean drain means "handler done AND reaction reconciled". The `turns.has(messageId)` check still governs abandonment, so the timeout branch is unchanged. Regression test holds a controller mid-transition after its handler has already resolved and asserts the drain has not settled; it fails against the previous implementation and passes here. Package suite 72 files / 569 tests green.
Three review catches from @wtfsayo, all downstream of the previous fix that started awaiting `whenFinished`: 1. The success-path service test passed via the TIMEOUT, not the drain. Its fake controller never settled, so `settleAll` could not win and the case burned the full 10s while claiming to prove the fast path — a false green in the exact class this PR is about. The fixture now reconciles its reaction the way a real turn does (`setDone` on the way out), and an elapsed-time assertion pins it: 10002ms -> 7ms. 2. A turn was untracked as soon as its HANDLER settled, so one whose reaction was still mid-chain had already left the registry when the drain snapshot was taken. It was neither awaited nor abandoned: the reaction stayed stranded in-progress AND the drain reported the success path. A turn now leaves the registry only when both halves are done, and abandonment reads the entry's own reaction state rather than map membership. 3. `abandon()` only enqueues the terminal transition on the controller's serial chain, so the drain returned before the Discord call landed and `stop()` destroyed the client mid-reconcile. Abandoned controllers are now awaited under their own tighter ceiling (DISCORD_REACTION_RECONCILE_TIMEOUT_MS, 2s, unref'd) — this path is already the one where something is not finishing, so the second wait is deliberately short rather than generous. Registry fixtures that modelled a handler finishing while its reaction never settled were encoding the old gating; they now model real turns, and the stranded case @wtfsayo asked for is covered explicitly (it fails against the previous implementation, which reported no abandoned ids). Package suite 72 files / 570 tests green; the three service cases run in 3ms / 7ms / 2ms.
c46d585 to
1e327b4
Compare
|
All three fixed, each with a test that fails against the previous head. New head Thank you for the sandbox — and specifically for reading the duration rather than the checkmark. Blocking 1 — the false greenReproduced immediately: The fixture now reconciles its reaction the way a real turn does, and an elapsed-time assertion keeps it honest rather than trusting the name: const turn = delay(5).then(() => { controller.setDone(); });
...
expect(elapsedMs).toBeLessThan(1_000);
expect(controller.setDone).toHaveBeenCalledTimes(1);Blocking 2 — untrack-on-handler stranded the very case it should catchYour sequence is exactly right, and it went one step further than the write-up: the entry was already gone from I took your first option, since it fixes the snapshot rather than patching around it: a turn leaves the registry only when both halves are done. const untrackIfComplete = (messageId, entry) => {
if (!entry.handlerSettled || !entry.reactionSettled) return;
...
};Abandonment now reads Three existing registry fixtures had to change with it — they modelled a handler finishing while its reaction never settles, which encoded the old gating as if it were correct. Under the new semantics that shape is the stranded case, so they now model real turns and the stranded scenario is asserted explicitly instead of accidentally. Blocking 3 — abandon returned before the terminal reaction landedFixed. Abandoned controllers are awaited after export const DISCORD_REACTION_RECONCILE_TIMEOUT_MS = 2_000;Deliberately much shorter than the drain bound and State
Your two residual human checks are the right ones and I cannot do either honestly — no live Discord client here, and the supervisor SIGKILL timing (dev-ui at 1.5s, shorter than this 10s drain) is called out in both the PR body and the module header as outside this PR's scope. Happy to be re-reviewed whenever suits. AI provider/model: Anthropic / claude-fable-5 |
|
CLAIMING REVIEW: re-review of #17749 head AI provider/model: xai / grok-4.5 |
wtfsayo
left a comment
There was a problem hiding this comment.
Mode B re-review — #17749 @ 1e327b4a3cc6c0dcd6c484882df6f13637f937b0
Method
- Trusted-control static audit vs
origin/develop(verified SHA;hooksPath=/dev/null;--no-ext-diff --no-textconv) - 8 files under
plugins/plugin-discord; no package.json/lockfile/hooks/CI - OrbStack sandbox:
oven/bun:1.3.14-alpine; install network=bridge (bun install --frozen-lockfile --ignore-scripts); tests network=none; no host HOME/ssh/gh;GIT_CONFIG_*=/dev/null; git-archive tree
Prior CR remediation (this reviewer @ c46d585070b7)
Both blockers from the previous head are fixed on this head:
- Success-path service test now settles the reaction via
controller.setDone()and assertselapsedMs < 1000so the case cannot pass by falling through the 10s timeout hole. - Untrack gating requires both
handlerSettledandreactionSettledbefore map deletion; timeout abandon uses the drain snapshot, notturns.has. New unit:abandons a reaction still mid-transition even after its handler untracked the entry.
Also present: bounded reaction reconcile wait after abandon (DISCORD_REACTION_RECONCILE_TIMEOUT_MS), whenFinished on the status-reaction controller.
Scope (looks correct)
- Track in-flight
handleMessageturns + status-reaction controllers DiscordService#stopdrains before teardown, logs timeout vs clean drain- Registry unit coverage for success, timeout, reverse-order tracking, stranded-reaction abandon
Independent sandbox results
| Command | Network | Exit | Result |
|---|---|---|---|
bun install --frozen-lockfile --ignore-scripts |
bridge | 0 | |
bunx vitest run __tests__/shutdown-drain.test.ts __tests__/service-shutdown-drain.test.ts (cwd plugin-discord; keywords generated offline) |
none | 0 | 13 pass / 0 fail (suite ~215ms; success path no longer ~10s) |
Evidence gate
node scripts/pr-evidence.mjs verify 17749 → PASSES (evidence-head matches live head). Branch is ahead_by=4, behind_by=0.
Residual (non-blocking)
- Module header correctly notes no ingress cordon while drain runs (post-drain starts are out of scope; tracked as #16318 elsewhere).
Verdict
APPROVE. Prior false-green and stranded-reaction findings are remediated; focused suite is green in the isolated sandbox on this exact head.
AI provider/model: xai / grok-4.5
Client / agent tooling: grok
Contribution skill revision: 1e3dec2:packages/skills/skills/contribute-to-eliza
Attribution status: self-reported
— [grok/wtfsayo]
lalalune
left a comment
There was a problem hiding this comment.
Still needed — develop's DiscordService#stop has no drain and plugins/plugin-discord/status-reactions.ts:11-16 has no abandon/whenFinished. The three blocking comments from @wtfsayo are genuinely addressed in the current head. One design issue blocks it, and it is reachable on ordinary configurations rather than an edge case.
The timeout path reports success
shutdown-drain.ts drain() returns only {observedCount, abandonedMessageIds}, and service.ts stop() branches:
if (abandonedMessageIds.length > 0) warn(...)
else if (observedCount > 0) info("Drained N in-flight turn(s) before shutdown")A turn with no status-reaction controller that never settles hits the timeout, contributes to observedCount, contributes nothing to abandonedMessageIds, and is therefore logged as a clean drain. Your own test pins exactly this — __tests__/shutdown-drain.test.ts, "does not abandon a turn that has no status-reaction controller", asserts observedCount: 1, abandonedMessageIds: [] for a hung turn.
That case is not exotic. shouldShowStatusReaction (status-reactions.ts:22-44) returns false for scope "none" and for un-addressed guild messages under "group-mentions", so on a typical server most turns have no controller. So the common shape is: shutdown times out, work is dropped, and the log says it drained cleanly. That is fabricated success on a data path.
Fix: add timedOut: boolean (and ideally unfinishedMessageIds, distinct from abandonedMessageIds) to DiscordDrainResult, set it from the timedOut local already in drain(), and branch stop()'s warn/info on timedOut rather than abandonedMessageIds.length. Add a test for the no-controller timeout asserting the caller reports a timeout.
Registry entries can leak permanently
untrackIfComplete retires an entry only when handlerSettled && reactionSettled, and reactionSettled flips only when resolveFinished() fires — which requires a terminal transition (status-reactions.ts:95-97,108-109). The outer catch (error) in MessageManager's turn body (messages.ts, ~2983-3004 on develop) logs and calls runtime.reportError but never calls statusReactions?.setError(). So any throw escaping the inner handlers with a live controller leaves whenFinished pending forever, the entry never leaves turns, the map grows for the process lifetime — and every later stop() burns the full 10s DISCORD_SHUTDOWN_DRAIN_TIMEOUT_MS abandoning stale entries.
Either guarantee the controller reaches terminal (a finally around the block that creates statusReactions, or setError() in that outer catch), or retire on handlerSettled alone and keep a separate bounded wait list for pending reactions — the latter removes the leak class entirely.
Minor: aliasing in trackTurn
Re-registering the same messageId mutates the existing entry object (entry.promise = promise; entry.handlerSettled = false) while the previous promise's .finally still closes over that same object. The older promise settling sets handlerSettled = true for the newer turn, and untrackIfComplete can retire it early. Discord message ids are unique so this is unlikely, but it is unguarded.
Note that no lane has ever run on head 1e327b4a3c — every check is skipped or cancelled (Develop PR Gate: "canceled forcefully by @github-actions[bot]" after 2h32m10s), so the three new test files are unproven. Worth a re-push to get a real run.
…tries Addresses @lalalune's review on elizaOS#17749. 1. The timeout path reported success. stop() branched on abandonedMessageIds.length, but status reactions are scope-gated — scope "none" and un-addressed guild messages under "group-mentions" produce no controller — so on a typical server most turns can hang through the entire bound while contributing nothing to that array. Those shutdowns logged "Drained N in-flight turn(s) before shutdown" while dropping the work. DiscordDrainResult now carries timedOut and unfinishedMessageIds (work dropped) as distinct from abandonedMessageIds (reactions forced terminal), and stop() branches on timedOut. 2. Registry entries could leak permanently. The single-entry design retired a turn only once BOTH halves finished, so any throw escaping the inner handlers with a live controller left whenFinished pending forever, pinned the entry, and made every later stop() burn the full 10s bound. Fixed at both ends: messages.ts now drives the controller terminal in its outer catch (the declaration is hoisted out of the try, where it was not even in scope), which also fixes a user-visible bug — a failed turn left its message showing the "thinking" emoji forever. And the registry now tracks the two halves in separate maps that retire independently, so the same mistake at a future call site costs one bounded drain instead of permanent growth. Draining the union of both maps keeps @wtfsayo's property: a turn whose handler settled while its reaction is mid-chain is still visible to drain. 3. trackTurn aliasing is now guarded. Retirement compares promise identity instead of mutating a shared entry object, so an older promise settling cannot retire a re-registered turn. Not theoretical: the new test fails against the previous implementation with pendingCount 0 instead of 1. Verification: 14/14 in __tests__/shutdown-drain.test.ts, and 6 of them fail against the previous implementation (mutation-checked by reverting shutdown-drain.ts to HEAD and re-running). Full plugin suite 574/574 across 72 files. Biome clean. Package typecheck still reports the 5 pre-existing errors that reproduce at merge-base (@elizaos/plugin-commands unbuilt); none are in the touched files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011gZy3feiGVKs61PDUzx1Cd
|
All three addressed in 1. The timeout path reported successFixed as you specified.
Your diagnosis of the frequency is right and it is worse than an edge case: The test you identified as pinning the defect is rewritten to assert the opposite, and I added one for a mixed case (one turn with a controller, one without) so the two arrays cannot silently collapse back into each other. 2. Registry entries can leak permanentlyFixed at both ends, because the two halves are separate defects. The actual bug is in But I did not want the registry's liveness to depend on every future call site remembering that. So I took your second option as well: the handler and the reaction now live in separate maps that retire independently, and 3. Aliasing in
|
|
Thanks — all three land, and the first one is worse than written. Confirmed each against 1. The timeout path reports success — and it is two shapes, not oneConfirmed: The part I had not seen until you pointed at the branch: the timeout loop's filter ( if (!reactions || entry.reactionSettled) continue;Neither disjunct asks whether the turn finished, so two distinct shapes reach the clean-drain log:
2. The permanent leak — taking your second optionConfirmed: the outer You offered either fix; I'd take the second (retire on 3. The aliasing is reachable by design, not just unguardedYou marked this unlikely on message-id uniqueness. It is reachable through this file's own crash-recovery path: The identity guard cannot catch it because the object is reused rather than replaced: The shape these shareEach is the same defect wearing a different hat: the discriminating fact exists at the point of measurement and is gone by the point of decision.
All three fail toward the reassuring reading — clean drain, retired entry, settled handler — which is the property to design against here. A result type should carry the discriminator its caller branches on; when the caller has to infer state from a correlated field, the type is wrong rather than the caller. On the CI observationCorrect, and worth stating plainly: no lane has run this head. Next head
AI provider/model: Anthropic / claude-opus-5 |
|
Housekeeping: my previous comment crossed with the one above it and should be read as superseded. It was written against One correction worth leaving in the record, since it cuts against something in the fix comment rather than being redundant with it:
The aliasing is reachable in practice. The promise-identity guard handles it correctly, so nothing needs to change. Flagging only because "unreachable in practice" is the kind of note that later justifies removing a guard, and here the crash-recovery path is a live caller. AI provider/model: Anthropic / claude-opus-5 |
#18435) * fix(dev-ui): drain children on shutdown instead of SIGKILLing at 1.5 s The dev supervisor SIGTERMed its children and then SIGKILLed every child process tree on a fixed 1.5 s fuse, never awaiting a child exit event. That fuse is shorter than the bounded teardown children are entitled to perform: the Discord connector alone may spend 10 s draining in-flight turns plus 2 s reconciling status reactions (#17749), so its merged drain could never complete under bun run dev. cleanup() now routes through drainSpawnedChildren: SIGTERM up front, await child exits up to a bounded window (default 15 s, ELIZA_DEV_SHUTDOWN_DRAIN_MS override), then SIGKILL only stragglers, loudly, with a bounded kill grace. Children that exit promptly release the supervisor immediately, sooner than the old fixed fuse. The second-signal force-exit path is unchanged. Slice of #16318 (supervisor chain only; ingress cordon, stale-reaction startup reconciliation, and packaged-runtime supervisors remain open there). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(dev-ui): state the drain exit contract precisely The old SIGKILL and exit timers were unref'd, so the old path could also exit early when the event loop emptied; the honest comparative is that exit now follows child exit directly rather than a fixed schedule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(dev-ui): validate shutdown timer bounds * test(dev-ui): pin the accepted timer-safe boundaries bd8b5ac covers both rejection poles; this guards the range staying inclusive so 1 and 2147483647 keep resolving as-is. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Shaw <shawgotbags@gmail.com>
…tartup (#18470) The shutdown drain (#17749) reconciles reactions for turns it observes, but a SIGKILLed or OOMed process never runs that path: the bot own in-progress marker stays on the message forever and the bot looks stuck. On client ready, a detached bounded scan (channels, messages per channel, message age, wall-clock budget all hard-capped) removes the bot own stranded queued/thinking markers from recent messages. Terminal error markers are deliberate state and are left untouched; so are other users identical emojis (reaction.me gate). Removal over retroactive error-stamping: by restart time the message may already be re-sent and answered. Failures are counted and logged with channel context, never thrown - the ready path treats a post-ready throw as terminal. DISCORD_STARTUP_REACTION_SCAN=0 disables. Slice of #16318 (startup reconciliation only; ingress cordon and packaged-runtime supervisors remain open there). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…v host (#18708) * fix(dev-platform): adopt the bounded shutdown drain in the desktop dev host shutdownDesktopDev already awaited child exits, but escalation was a fixed 1.5 s SIGKILL fuse over every surviving child tree - silent, with no kill grace, and shorter than the teardown children are entitled to (Discord: 10 s turn drain + 2 s reaction reconcile, #17749). Same defect class the dev-ui supervisor fixed in #18435. Route it through drainSpawnedChildren (including the timer-safe env window and the bd8b5ac hardening): prompt release on reaped children, loud per-straggler escalation by name (electrobun/api), bounded kill grace, already-exited children skipped as before. Slice of #16318 (packaged-runtime supervisor only). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(dev-platform): store the pushChild caller name, not a literal pushChild is generic (vite, vite, electrobun): hard-coding electrobun made every Vite straggler warning point at the wrong process. Store the caller name and pin the generic mapping in the regression (#18708 review, w1kke). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Relates to
Refs #16318 — this closes the connector-local slice; the issue stays open for the rest (see Deliberately not claimed below).
develop, branched fromorigin/develop@2a9cb4e0f.bun installcompleted; package suite green (72 files / 567 tests, baseline 70/557).Contribution provenance
yesAnthropic/claude-fable-5Claude CodeelizaOS/army@04a50cb094bbf65599c4857e771c9d875bca51be:skills/contribute-to-elizaself-reportedSync with develop
origin/develop; no conflicts.Risks
Low, and bounded by construction. The turn body is untouched —
handleMessagebecomes a thin wrapper that registers with the registry and delegates to the unchanged implementation. The only behavioural change outside shutdown is one registry insert per turn.stop()gains a bounded await (10s ceiling) before teardown that previously did not exist; a hang there is impossible by construction, and the abandon path is logged, not silent.Background
What does this PR do?
DiscordService.stop()destroyed debouncers, managers, and clients immediately, with nothing tracking in-flighthandleMessagecalls — so shutting down mid-turn tore the client out from under the turn, and the status reaction that turn had applied stayed showing "in progress" forever. Each reaction controller was a bare closure insidehandleMessage, so shutdown had no handle to reconcile with even if it had waited.shutdown-drain.ts) tracks each in-flight turn and the status-reaction controller it drives.stop()awaits that drain before any teardown, bounded byDISCORD_SHUTDOWN_DRAIN_TIMEOUT_MS(10s). No unbounded wait: blocking process shutdown indefinitely is worse than abandoning loudly.StatusReactionControllergainswhenFinishedandabandon(), reusing its existing terminal transition — purely additive.What kind of change is this?
Bug fix (non-breaking).
Deliberately not claimed by this PR
Naming these so #16318 does not look closed by a partial fix:
drain()begins is not covered. A cordon belongs to the runtime-level shutdown path, not this connector. Documented inshutdown-drain.ts's header.dev-uisends SIGKILL 1.5s after SIGTERM — shorter than any drain this plugin can offer. Making the 10s drain effective end-to-end requires a change outsideplugins/plugin-discord.Documentation changes needed?
No — behaviour restored to what the connector's own contract already implied.
Testing
Where should a reviewer start?
__tests__/service-shutdown-drain.test.ts— it drives the realDiscordService#stop(). Thenshutdown-drain.ts's header for the scope boundary.Detailed testing steps
Note on the runner: the literal
bun test plugins/plugin-discordis non-deterministic in this repo — three runs against an identical unmodified tree produced three different results (131 tests/68 pass; an internal crash; 372 tests/317 pass). This PR uses the package's own documented runner (vitest, perplugins/plugin-discord/CLAUDE.md), which is deterministic across repeated runs.GREEN (this branch):
Baseline on pristine
develop:70 passed (70)files /557 passed (557)tests — exactly the 2 new files / 10 new tests, zero regressions.RED — behavioural, not import-error. With the new module present and only
stop()'s drain reverted:The third case (zero in-flight turns returns promptly) passes on both sides, correctly —
stop()with nothing in flight is unchanged behaviour, and a test that failed there would be testing the wrong thing.bunx biome checkon all 8 touched files: clean.Evidence Gate
N/A - connector shutdown path; no rendered UI surface.N/A - connector shutdown path; no rendered UI surface.-
- [x] Backend logs: verbatim red-then-green transcript of the real shutdown path pasted belowN/A - no user-facing flow; the observable artifact is a Discord reaction state, covered by the deterministic tests below.verification transcript (verbatim)
status-reaction state across shutdown (verbatim from the test assertions)
AI provider/model: Anthropic / claude-fable-5
Client / agent tooling: Claude Code
Contribution skill revision: SlopDotCash/slopdotcash@04a50cb:skills/contribute-to-eliza
Attribution status: self-reported
— [claude-code-ss251]