Skip to content

Stop a crash on app exit / mode switch: discard UtcTimer pool submits during teardown - #618

Merged
patrickrb merged 2 commits into
devfrom
optio/task-f5be3f84-c75e-4e5d-b140-f8261313bdd9
Jul 22, 2026
Merged

Stop a crash on app exit / mode switch: discard UtcTimer pool submits during teardown#618
patrickrb merged 2 commits into
devfrom
optio/task-f5be3f84-c75e-4e5d-b140-f8261313bdd9

Conversation

@patrickrb

Copy link
Copy Markdown
Owner

Summary

UtcTimer.delete() can crash the app with an uncaught RejectedExecutionException when the timer is torn down while one of its own ticks is submitting to the thread pool being shut down. This happens on every app exit and every FT8/FT4/FT2 mode switch, so it is a genuine (if intermittent) instability, not a theoretical one.

Root cause

UtcTimer runs two java.util.Timer tasks:

  • secTask — a 10 ms cycle-boundary check that, at a slot boundary, does cachedThreadPool.execute(doSomething).
  • heartBeatTask — a 1 s heartbeat that always does heartBeatThreadPool.execute(doHeartBeat).

delete() cancels both Timers and then calls shutdownNow() on both pools:

public void delete() {
    secTimer.cancel();
    heartBeatTimer.cancel();
    cachedThreadPool.shutdownNow();
    heartBeatThreadPool.shutdownNow();
}

Timer.cancel() does not wait for a TimerTask that is already running. So a tick can be mid-run() — about to call pool.execute() — at the instant delete() shuts that pool down from another thread. With the default AbortPolicy, execute() then throws RejectedExecutionException. secTask's catch only handles InterruptedException, and heartBeatTask has no catch at all, so the exception escapes TimerTask.run(), terminates the Timer thread, and reaches the process's default uncaught-exception handler → app crash.

Reachability: delete() is called from ComposeMainActivity.onDestroy (every app exit) and from FT8SignalListener.rebuildTimer / FT8TransmitSignal.rebuildTimer on every operating-mode switch — all while the 1 s heartbeat is live, so the window is exercised routinely across the user base.

Fix

Construct both pools with a ThreadPoolExecutor.DiscardPolicy rejected-execution handler (otherwise identical to Executors.newCachedThreadPool()). A submit that loses the race with shutdown is now silently discarded — the correct behaviour during teardown, since the cycle/heartbeat callback is moot once we are shutting down.

This is a no-op in normal operation: with an unbounded maximum pool size over a SynchronousQueue, a submit is never rejected until the pool has been shut down. The change is localized to pool construction; the timer/callback logic is untouched.

Testing

./gradlew :app:testDebugUnitTest --tests com.k1af.ft8af.timer.UtcTimerTest — all 29 cases pass, including 3 new pure-JVM tests:

  • discardingPool_doesNotThrowWhenSubmittingAfterShutdown — the exact crashing path (execute after shutdownNow) no longer throws.
  • defaultCachedPool_throwsWhenSubmittingAfterShutdown_documentingTheBug — a vanilla cached pool still throws there, pinning the pre-fix behaviour.
  • discardingPool_stillRunsSubmittedWorkBeforeShutdown — normal-path work still executes.

Risk assessment

Low. No protocol/DSP/timing behaviour changes — the firing instants, callbacks, and pool semantics in normal operation are identical (DiscardPolicy only activates post-shutdown). No native code touched; performance characteristics unchanged.

🤖 Generated with Claude Code

Root cause
----------
UtcTimer runs two java.util.Timer tasks: a 10 ms cycle-boundary check
(secTask) and a 1 s heartbeat (heartBeatTask). Each tick submits its
callback to a cached thread pool via execute(). delete() tears the timer
down by cancelling both Timers and then calling shutdownNow() on both
pools.

Timer.cancel() does not wait for a TimerTask that is already running, so a
tick can be mid-run() — about to call pool.execute() — at the instant
delete() shuts that pool down from another thread. With the default
AbortPolicy, execute() then throws RejectedExecutionException. secTask's
catch only handles InterruptedException and heartBeatTask has no catch at
all, so the exception escapes TimerTask.run(), terminates the Timer thread,
and reaches the process's default uncaught-exception handler — crashing the
app. delete() runs on every app exit (ComposeMainActivity.onDestroy) and
every FT8/FT4/FT2 mode switch (rebuildTimer), while the 1 s heartbeat is
always live, so the race is exercised routinely.

Fix
---
Build both pools with a ThreadPoolExecutor.DiscardPolicy rejected-execution
handler (otherwise identical to Executors.newCachedThreadPool()). A submit
that loses the race with shutdown is now silently dropped — the correct
behaviour during teardown, since the cycle/heartbeat callback is moot once
we are shutting down. This is a no-op in normal operation: with an unbounded
maximum pool size over a SynchronousQueue, a submit is never rejected until
the pool has been shut down.

Testing
-------
Added pure-JVM tests to UtcTimerTest: the discarding pool tolerates a
submit after shutdownNow() (the crashing path), a vanilla cached pool still
throws there (documenting the pre-fix bug), and the discarding pool still
runs work submitted before shutdown. Full :app:testDebugUnitTest passes
(29 UtcTimerTest cases).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 36.62%. Comparing base (c1696cb) to head (85bbd78).
⚠️ Report is 7 commits behind head on dev.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff            @@
##                dev     #618   +/-   ##
=========================================
  Coverage     36.62%   36.62%           
  Complexity      197      197           
=========================================
  Files           216      216           
  Lines         26885    26885           
  Branches       3294     3294           
=========================================
  Hits           9847     9847           
  Misses        16811    16811           
  Partials        227      227           
Flag Coverage Δ
android 15.03% <ø> (ø)
native 9.93% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.
see 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request hardens UtcTimer teardown so app exit and FT8/FT4/FT2 mode switches don’t intermittently crash due to a RejectedExecutionException race between TimerTask.run() submitting work and delete() shutting down the underlying executors.

Changes:

  • Replace Executors.newCachedThreadPool() with a cached ThreadPoolExecutor configured to discard late submits after shutdown to avoid RejectedExecutionException during teardown.
  • Add unit tests that reproduce the pre-fix rejection behavior and validate the new pool’s post-shutdown behavior and normal operation.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
ft8af/app/src/main/java/com/k1af/ft8af/timer/UtcTimer.java Introduces newDiscardingCachedThreadPool() and switches both timer executors to use it to prevent teardown-time crashes.
ft8af/app/src/test/java/com/k1af/ft8af/timer/UtcTimerTest.java Adds regression tests documenting the old crash behavior and verifying the new executor’s behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread ft8af/app/src/main/java/com/k1af/ft8af/timer/UtcTimer.java Outdated
…down

DiscardPolicy drops every rejection, so a rejection during normal operation
(e.g. thread creation failing under resource exhaustion) would silently
swallow the cycle/heartbeat callback. DiscardOnShutdownPolicy discards only
when executor.isShutdown() — the teardown race this fix targets — and
delegates everything else to AbortPolicy, keeping real failures visible.

New test: a saturated still-running pool with the policy installed still
throws RejectedExecutionException.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@patrickrb
patrickrb merged commit edeb9e5 into dev Jul 22, 2026
17 checks passed
@patrickrb
patrickrb deleted the optio/task-f5be3f84-c75e-4e5d-b140-f8261313bdd9 branch July 22, 2026 22:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants