Skip to content

Commit 72522ea

Browse files
committed
Detect accidentally dropped errors in tests
Manifold has a debug mechanism which instruments deferreds so that a warning is logged when they are in an error state with no error handler attached at garbage collection time. Such errors are referred to as "dropped errors" and the instrumented deferreds are called "leak-aware". The dropped error logging feature is enabled by default but for performance reasons, only every 1024th deferred is made leak-aware (see `manifold.deferred/deferred`). However, deferreds constructed via `manifold.deferred/error-deferred` are always leak-aware. This patch introduces a new option `manifold.debug/*leak-aware-deferred-rate*` which allows changing the rate of leak-aware deferreds being instantiated, with default still being 1024. When this option is set to to 1 (= every deferred will be leak-aware), dropped errors can be detected more reliably e.g. during testing. To that end, the patch also includes a test util for instrumenting all tests in a namespace so that they fail when a dropped error was detected. All test namespaces are now instrumented like this (except for `:benchmark` and `:stress` tests), which already uncovered a few latent dropped errors. These will be fixed in separate patches.
1 parent 82bd84b commit 72522ea

File tree

10 files changed

+128
-26
lines changed

10 files changed

+128
-26
lines changed

src/manifold/debug.clj

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
(ns manifold.debug
2-
{:no-doc true})
2+
{:no-doc true}
3+
(:require [clojure.tools.logging :as log]))
34

45
(def ^:dynamic *dropped-error-logging-enabled?* true)
56

@@ -8,3 +9,30 @@
89

910
(defn disable-dropped-error-logging! []
1011
(.bindRoot #'*dropped-error-logging-enabled?* false))
12+
13+
(def ^:dynamic *leak-aware-deferred-rate* 1024)
14+
15+
(defn set-leak-aware-deferred-rate! [n]
16+
(.bindRoot #'*leak-aware-deferred-rate* n))
17+
18+
(def dropped-errors nil)
19+
20+
(defn log-dropped-error! [error]
21+
(some-> dropped-errors (swap! inc))
22+
(log/warn error "unconsumed deferred in error state, make sure you're using `catch`."))
23+
24+
(defn with-dropped-error-detection
25+
"Calls f, then attempts to trigger dropped errors to be detected and finally calls
26+
handle-dropped-errors with the number of detected dropped errors. Details about these are logged
27+
as warnings."
28+
[f handle-dropped-errors]
29+
(assert (nil? dropped-errors) "with-dropped-error-detection may not be nested")
30+
;; Flush out any pending dropped errors from before
31+
(System/gc)
32+
(System/runFinalization)
33+
(with-redefs [dropped-errors (atom 0)]
34+
(f)
35+
;; Flush out any errors which were dropped during f
36+
(System/gc)
37+
(System/runFinalization)
38+
(handle-dropped-errors @dropped-errors)))

src/manifold/deferred.clj

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -481,8 +481,10 @@
481481
[Object
482482
(finalize [_]
483483
(utils/with-lock lock
484-
(when (and (identical? ::error state) (not consumed?))
485-
(log/warn val "unconsumed deferred in error state, make sure you're using `catch`."))))]
484+
(when (and (identical? ::error state)
485+
(not consumed?)
486+
debug/*dropped-error-logging-enabled?*)
487+
(debug/log-dropped-error! val))))]
486488
nil)
487489

488490
clojure.lang.IReference
@@ -631,7 +633,7 @@
631633
(when (and
632634
(not consumed?)
633635
debug/*dropped-error-logging-enabled?*)
634-
(log/warn error "unconsumed deferred in error state, make sure you're using `catch`.")))
636+
(debug/log-dropped-error! error)))
635637

636638
clojure.lang.IReference
637639
(meta [_] mta)
@@ -690,8 +692,9 @@
690692
([]
691693
(deferred (ex/executor)))
692694
([executor]
693-
(if (and (p/zero? (rem (.incrementAndGet created) 1024))
694-
debug/*dropped-error-logging-enabled?*)
695+
(if (and debug/*dropped-error-logging-enabled?*
696+
(p/zero? (rem (.incrementAndGet created)
697+
^long debug/*leak-aware-deferred-rate*)))
695698
(LeakAwareDeferred. nil ::unset nil (utils/mutex) (LinkedList.) nil false executor)
696699
(Deferred. nil ::unset nil (utils/mutex) (LinkedList.) nil false executor)))))
697700

test/manifold/bus_test.clj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,5 @@
2323
d (b/publish! b (long 1) 42)]
2424
(is (= 42 @(s/take! s)))
2525
(is (= true @d))))
26+
27+
(instrument-tests-with-dropped-error-detection!)

test/manifold/deferred_stage_test.clj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
(ns manifold.deferred-stage-test
22
(:require [manifold.deferred :as d]
3+
[manifold.test-utils :refer :all]
34
[manifold.utils :refer
45
[fn->Function fn->Consumer fn->BiFunction fn->BiConsumer]]
56
[clojure.test :refer [deftest is testing]])
@@ -598,3 +599,5 @@
598599
(d/success-deferred (d/success-deferred x)))))]
599600

600601
(is (d/deferred? @d2)))))
602+
603+
(instrument-tests-with-dropped-error-detection!)

test/manifold/deferred_test.clj

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
(:require
44
[clojure.test :refer :all]
55
[manifold.test-utils :refer :all]
6+
[manifold.debug :as debug]
67
[manifold.deferred :as d]
78
[manifold.executor :as ex])
89
(:import
910
(java.util.concurrent
1011
CompletableFuture
11-
CompletionStage)
12+
CompletionStage
13+
TimeoutException)
1214
(manifold.deferred IDeferred)))
1315

1416
(defmacro future' [& body]
@@ -314,7 +316,7 @@
314316

315317
;;;
316318

317-
(deftest ^:benchmark benchmark-chain
319+
(deftest ^:ignore-dropped-errors ^:benchmark benchmark-chain
318320
(bench "invoke comp x1"
319321
((comp inc) 0))
320322
(bench "chain x1"
@@ -334,7 +336,7 @@
334336
(bench "chain' x5"
335337
@(d/chain' 0 inc inc inc inc inc)))
336338

337-
(deftest ^:benchmark benchmark-deferred
339+
(deftest ^:ignore-dropped-errors ^:benchmark benchmark-deferred
338340
(bench "create deferred"
339341
(d/deferred))
340342
(bench "add-listener and success"
@@ -381,16 +383,19 @@
381383
(deliver d 1)
382384
@d)))
383385

384-
(deftest ^:stress test-error-leak-detection
386+
(deftest ^:ignore-dropped-errors ^:stress test-error-leak-detection
387+
(testing "error-deferred always detects dropped errors"
388+
(expect-dropped-errors 1
389+
(d/error-deferred (Throwable.))))
385390

386-
(d/error-deferred (Throwable.))
387-
(System/gc)
391+
(testing "regular deferreds detect errors on every debug/*leak-aware-deferred-rate*'th instance (1024 by default)"
392+
(expect-dropped-errors 2
393+
;; Explicitly restating the (current) default here for clarity
394+
(binding [debug/*leak-aware-deferred-rate* 1024]
395+
(dotimes [_ 2048]
396+
(d/error! (d/deferred) (Throwable.)))))))
388397

389-
(dotimes [_ 2e3]
390-
(d/error! (d/deferred) (Throwable.)))
391-
(System/gc))
392-
393-
(deftest ^:stress test-deferred-chain
398+
(deftest ^:ignore-dropped-errors ^:stress test-deferred-chain
394399
(dotimes [_ 1e4]
395400
(let [d (d/deferred)
396401
result (d/future
@@ -401,7 +406,7 @@
401406
(d/connect % d')
402407
d')
403408
d))))]
404-
(Thread/sleep (rand-int 10))
409+
(Thread/sleep ^long (rand-int 10))
405410
(d/success! d 1)
406411
(is (= 1 @@result)))))
407412

@@ -431,3 +436,5 @@
431436

432437
(finally
433438
(remove-method print-method CompletionStage))))
439+
440+
(instrument-tests-with-dropped-error-detection!)

test/manifold/executor_test.clj

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
(ns manifold.executor-test
22
(:require
33
[clojure.test :refer :all]
4-
[manifold.executor :as e])
4+
[manifold.executor :as e]
5+
[manifold.test-utils :refer :all])
56
(:import
67
[io.aleph.dirigiste
78
Executor
@@ -68,3 +69,5 @@
6869
500)
6970
thread (.newThread tf (constantly nil))]
7071
(is (= "custom-name" (.getName thread)))))
72+
73+
(instrument-tests-with-dropped-error-detection!)

test/manifold/go_off_test.clj

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@
149149
(s/close! test-stream)
150150
(is (= @test-d [0 1 2 nil nil]))))
151151

152-
(deftest ^:benchmark benchmark-go-off
152+
(deftest ^:ignore-dropped-errors ^:benchmark benchmark-go-off
153153
(bench "invoke comp x1"
154154
((comp inc) 0))
155155
(bench "go-off x1"
@@ -174,3 +174,5 @@
174174
@(go-off (inc (<!? (inc (<!? (inc (<!? (inc (<!? (inc (<!? (d/success-deferred 0))))))))))))
175175
(bench "go-off future 200 x5"
176176
@(go-off (inc (<!? (inc (<!? (inc (<!? (inc (<!? (inc (<!? (d/future (Thread/sleep 200) 0)))))))))))))))
177+
178+
(instrument-tests-with-dropped-error-detection!)

test/manifold/stream_test.clj

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -480,7 +480,7 @@
480480

481481
;;;
482482

483-
(deftest ^:stress stress-buffered-stream
483+
(deftest ^:ignore-dropped-errors ^:stress stress-buffered-stream
484484
(let [s (s/buffered-stream identity 100)]
485485
(future
486486
(dotimes [_ 1e6]
@@ -518,7 +518,7 @@
518518
(dotimes [_ 1e3]
519519
@(s/take! s)))
520520

521-
(deftest ^:benchmark benchmark-conveyance
521+
(deftest ^:ignore-dropped-errors ^:benchmark benchmark-conveyance
522522
(let [s (s/stream)
523523
s' (reduce
524524
(fn [s _]
@@ -542,7 +542,7 @@
542542
(async/go (async/>! c 1))
543543
(async/<!! c'))))
544544

545-
(deftest ^:benchmark benchmark-map
545+
(deftest ^:ignore-dropped-errors ^:benchmark benchmark-map
546546
(let [s (s/stream)
547547
s' (reduce
548548
(fn [s _] (s/map inc s))
@@ -560,7 +560,7 @@
560560
(async/go (async/>! c 1))
561561
(async/<!! c'))))
562562

563-
(deftest ^:benchmark benchmark-alternatives
563+
(deftest ^:ignore-dropped-errors ^:benchmark benchmark-alternatives
564564
(let [q (ArrayBlockingQueue. 1024)]
565565
(bench "blocking queue throughput w/ 1024 buffer"
566566
(blocking-queue-benchmark q)))
@@ -589,7 +589,7 @@
589589
(bench "core.async blocking channel throughput w/ no buffer"
590590
(core-async-blocking-benchmark ch))))
591591

592-
(deftest ^:benchmark benchmark-streams
592+
(deftest ^:ignore-dropped-errors ^:benchmark benchmark-streams
593593
(let [s (s/stream 1024)]
594594
(bench "stream throughput w/ 1024 buffer"
595595
(stream-benchmark s)))
@@ -611,3 +611,5 @@
611611
(s/consume (fn [_]) s)
612612
(bench "put! with consume"
613613
(s/put! s 1))))
614+
615+
(instrument-tests-with-dropped-error-detection!)

test/manifold/test_utils.clj

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
(ns manifold.test-utils
22
(:require
3-
[criterium.core :as c]))
3+
[clojure.test :as test]
4+
[criterium.core :as c]
5+
[manifold.debug :as debug]))
46

57
(defmacro long-bench [name & body]
68
`(do
@@ -16,3 +18,51 @@
1618
(do ~@body)
1719
:reduce-with #(and %1 %2))))
1820

21+
(defn report-dropped-errors! [dropped-errors]
22+
(when (pos? dropped-errors)
23+
;; We include the assertion here within the `when` form so that we don't add a mystery assertion
24+
;; to every passing test (which is the common case).
25+
(test/is (zero? dropped-errors)
26+
"Dropped errors detected! See log output for details.")))
27+
28+
(defn instrument-test-fn-with-dropped-error-detection [tf]
29+
(if (or (::detect-dropped-errors? tf)
30+
(:ignore-dropped-errors tf))
31+
tf
32+
(with-meta
33+
(fn []
34+
(binding [debug/*leak-aware-deferred-rate* 1]
35+
(debug/with-dropped-error-detection tf report-dropped-errors!)))
36+
{::detect-dropped-errors? true})))
37+
38+
(defn instrument-tests-with-dropped-error-detection!
39+
"Instrument all tests in the current namespace dropped error detection by wrapping them in
40+
`manifold.debug/with-dropped-error-detection`. If dropped errors are detected, a corresponding (failing)
41+
assertion is injected into the test and the leak reports are logged at level `error`.
42+
43+
Usually placed at the end of a test namespace.
44+
45+
Add `:ignore-dropped-errors` to a test var's metadata to skip it from being instrumented.
46+
47+
Note that this is intentionally not implemented as a fixture since there is no clean way to make a
48+
test fail from within a fixture: Neither a failing assertion nor throwing an exception will
49+
preserve which particular test caused it. See
50+
e.g. https://github.com/technomancy/leiningen/issues/2694 for an example of this."
51+
[]
52+
(->> (ns-interns *ns*)
53+
vals
54+
(filter (comp :test meta))
55+
(run! (fn [tv]
56+
(when-not (:ignore-dropped-errors (meta tv))
57+
(alter-meta! tv update :test instrument-test-fn-with-dropped-error-detection))))))
58+
59+
(defmacro expect-dropped-errors
60+
"Expect n number of dropped errors after executing body in the form of a test assertion.
61+
62+
Add `:ignore-dropped-errors` to the a test's metadata to be able to use this macro in an
63+
instrumented namespace (see `instrument-tests-with-dropped-error-detection!`)."
64+
[n & body]
65+
`(debug/with-dropped-error-detection
66+
(fn [] ~@body)
67+
(fn [n#]
68+
(test/is (= ~n n#) "Expected number of dropped errors doesn't match detected number of dropped errors."))))

test/manifold/time_test.clj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,5 @@
8181
(is (= 1 @counter))
8282
(t/advance c 1)
8383
(is (= 1 @counter))))
84+
85+
(instrument-tests-with-dropped-error-detection!)

0 commit comments

Comments
 (0)