fix(typescript): bound kill switch callbacks with a timeout - #3783
Open
Amrik Singh Khalsa (amriksingh0786) wants to merge 2 commits into
Open
Conversation
kill() awaited termination and compensation callbacks in an unbounded loop, so a callback that never resolved blocked the kill path at exactly the moment it was needed. The Python port bounds this at DEFAULT_CALLBACK_TIMEOUT_SECONDS = 5.0; the TypeScript port had no bound. Race each callback against a configurable timeout (callbackTimeoutMs, default 5000 for parity with Python) and abandon it if it overruns. The same loop also had no try, so a callback that threw propagated out of kill() and the result was never pushed to history, losing the record of the kill entirely. Callbacks that reject are now reported as not executed instead of aborting the kill. callbacksExecuted and compensationsExecuted now count callbacks that completed cleanly rather than callbacks that were registered, so a hung or failed callback is visible in the result. Closes microsoft#3741 Signed-off-by: AMRIK <singhamrikkhalsa@gmail.com>
Amrik Singh Khalsa (amriksingh0786)
requested review from
MohammadHaroonAbuomar and
liamcrumm
as code owners
August 18, 2026 22:17
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
|
Welcome to the Agent Governance Toolkit! Thanks for your first pull request. |
PR Review Summary
Verdict: AI review comments are untrusted advisory output. The summary reports workflow-generated completion status only, not model-authored pass/fail claims. |
Author
|
@microsoft-github-policy-service agree |
1 similar comment
Author
|
@microsoft-github-policy-service agree |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
KillSwitch.kill()awaited termination and compensation callbacks in an unbounded loop, so a callback that never resolved blocked the kill path at exactly the moment it was needed. This races each callback against a configurable timeout and abandons it if it overruns.Closes #3741.
Problem
Two defects in the same loop (
src/kill-switch.ts:54-60).A hung callback blocks forever. This is what #3741 reports. The Python port bounds this at
DEFAULT_CALLBACK_TIMEOUT_SECONDS = 5.0and explains why in a source comment: "A slow or hung callback must not freeze the kill flow, the whole point of a kill switch is responsiveness." The TypeScript port had no bound.A throwing callback loses the record of the kill. Not in the issue. There was no
tryeither, so a rejection propagated out ofkill()andthis.history.push(result)at:73never ran. Measured against the current method:Both are the same three lines and the same fix, so they are handled together here.
Changes
src/types.tscallbackTimeoutMs?: numberonKillSwitchConfig, documented as the per-callback budgetsrc/kill-switch.tsDEFAULT_CALLBACK_TIMEOUT_MS = 5000; both loops now go through a privaterunBoundedthat races the callback against a timer and catches rejectiontests/kill-switch.test.tsA hung callback cannot be cancelled in JavaScript, so it is abandoned and left pending. Python abandons a daemon thread for the same reason, so the semantics match.
Two things worth review attention
callbacksExecutedsemantics changed. It washandlers.length, which counted callbacks that were registered. It now counts callbacks that completed cleanly, so a hung or failed callback is visible in the result. Without this the timeout would be silent, which seemed worse than the hang. Existing assertions still pass, but this is a deliberate behavior change rather than an accident.kill()no longer propagates callback errors. It records the kill and reports the callback as not executed, matching Python. Also a behavior change, and easy to split out if you would rather.Relationship to #3750
#3750 adds
terminatedtoKillSwitchResultand sets it tohandlers.length > 0. This PR deliberately does not touch that field, to stay off its toes.The two compose in either merge order. If this lands first,
terminatedcan be derived from the per-callback outcomerunBoundedalready returns rather than from whether a handler was registered, which is what the Python field means (kill_switch.py:201-235:Trueif the callback completed cleanly within the timeout,Falseif it timed out or raised). If #3750 lands first, I will rebase and wireterminatedto the same outcome. Happy to do that as a follow-up or fold it in here, whichever you prefer.Testing
The 8 kill-switch tests pass, including the 6 new ones. The suite emits a worker-teardown warning about leaking timers, but that reproduces on
mainwith these changes stashed, andnpx jest tests/kill-switch.test.ts --detectOpenHandlesreports no open handles for this file. Timers here are cleared in afinally.New tests use a 20ms
callbackTimeoutMsrather than fake timers, so nothing waits five seconds.