Skip to content

Commit 5cd0c24

Browse files
committed
Explicit naming for CancellableContinuation modes, drop suspendAtomicCancellableCoroutine
* MODE_ATOMIC_DEFAULT split into MODE_ATOMIC (for dispatch) and MODE_ATOMIC_REUSABLE (for suspendCancellableCoroutineReusable only). Dispatch modes are orthogonal to additional REUSE capability now. * Better documentation for MODE_XXX constants. * suspendCancellableCoroutineReusable does not have a default mode anymore, so its use is more explicit. * Completely drop (inline) suspendAtomicCancellableCoroutine. Any kind of legacy code where this call might have been inlined still works because the constant value of MODE_ATOMIC = 0 is retained and carries its legacy meaning (no continuation reuse). * Added stress test for proper handling of MODE_CANCELLABLE_REUSABLE and fixed test for #1123 bug with job.join (working in MODE_CANCELLABLE) that was not properly failing in the absence of the proper code in CancellableContinuationImpl.getResult
1 parent 1952649 commit 5cd0c24

File tree

11 files changed

+126
-89
lines changed

11 files changed

+126
-89
lines changed

benchmarks/src/jmh/kotlin/benchmarks/tailcall/SimpleChannel.kt

+4-4
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,12 @@ class NonCancellableChannel : SimpleChannel() {
7070
}
7171

7272
class CancellableChannel : SimpleChannel() {
73-
override suspend fun suspendReceive(): Int = suspendAtomicCancellableCoroutine {
73+
override suspend fun suspendReceive(): Int = suspendCancellableCoroutine {
7474
consumer = it.intercepted()
7575
COROUTINE_SUSPENDED
7676
}
7777

78-
override suspend fun suspendSend(element: Int) = suspendAtomicCancellableCoroutine<Unit> {
78+
override suspend fun suspendSend(element: Int) = suspendCancellableCoroutine<Unit> {
7979
enqueuedValue = element
8080
producer = it.intercepted()
8181
COROUTINE_SUSPENDED
@@ -84,13 +84,13 @@ class CancellableChannel : SimpleChannel() {
8484

8585
class CancellableReusableChannel : SimpleChannel() {
8686
@Suppress("INVISIBLE_MEMBER")
87-
override suspend fun suspendReceive(): Int = suspendAtomicCancellableCoroutineReusable {
87+
override suspend fun suspendReceive(): Int = suspendCancellableCoroutineReusable(MODE_ATOMIC_REUSABLE) {
8888
consumer = it.intercepted()
8989
COROUTINE_SUSPENDED
9090
}
9191

9292
@Suppress("INVISIBLE_MEMBER")
93-
override suspend fun suspendSend(element: Int) = suspendAtomicCancellableCoroutineReusable<Unit> {
93+
override suspend fun suspendSend(element: Int) = suspendCancellableCoroutineReusable<Unit>(MODE_ATOMIC_REUSABLE) {
9494
enqueuedValue = element
9595
producer = it.intercepted()
9696
COROUTINE_SUSPENDED

kotlinx-coroutines-core/api/kotlinx-coroutines-core.api

-3
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,6 @@ public class kotlinx/coroutines/CancellableContinuationImpl : kotlin/coroutines/
8080

8181
public final class kotlinx/coroutines/CancellableContinuationKt {
8282
public static final fun disposeOnCancellation (Lkotlinx/coroutines/CancellableContinuation;Lkotlinx/coroutines/DisposableHandle;)V
83-
public static final fun suspendAtomicCancellableCoroutine (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
84-
public static final fun suspendAtomicCancellableCoroutine (ZLkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
85-
public static synthetic fun suspendAtomicCancellableCoroutine$default (ZLkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
8683
public static final fun suspendCancellableCoroutine (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
8784
}
8885

kotlinx-coroutines-core/common/src/CancellableContinuation.kt

+11-36
Original file line numberDiff line numberDiff line change
@@ -204,30 +204,18 @@ public suspend inline fun <T> suspendCancellableCoroutine(
204204
}
205205

206206
/**
207-
* Suspends the coroutine like [suspendCancellableCoroutine], but with *atomic cancellation*.
207+
* Suspends the coroutine similar to [suspendCancellableCoroutine], but an instance of
208+
* [CancellableContinuationImpl] is reused.
208209
*
209-
* When the suspended function throws a [CancellationException], it means that the continuation was not resumed.
210-
* As a side-effect of atomic cancellation, a thread-bound coroutine (to some UI thread, for example) may
211-
* continue to execute even after it was cancelled from the same thread in the case when the continuation
212-
* was already resumed and was posted for execution to the thread's queue.
213-
*
214-
* @suppress **This an internal API and should not be used from general code.**
215-
*/
216-
@InternalCoroutinesApi
217-
public suspend inline fun <T> suspendAtomicCancellableCoroutine(
218-
crossinline block: (CancellableContinuation<T>) -> Unit
219-
): T =
220-
suspendCoroutineUninterceptedOrReturn { uCont ->
221-
val cancellable = CancellableContinuationImpl(uCont.intercepted(), resumeMode = MODE_ATOMIC_DEFAULT)
222-
block(cancellable)
223-
cancellable.getResult()
224-
}
225-
226-
/**
227-
* Suspends coroutine similar to [suspendAtomicCancellableCoroutine], but an instance of [CancellableContinuationImpl] is reused if possible.
210+
* * when [resumeMode] is [MODE_CANCELLABLE_REUSABLE] works like [suspendCancellableCoroutine].
211+
* * when [resumeMode] is [MODE_ATOMIC_REUSABLE] it has *atomic cancellation*.
212+
* When the suspended function throws a [CancellationException], it means that the continuation was not resumed.
213+
* As a side-effect of atomic cancellation, a thread-bound coroutine (to some UI thread, for example) may
214+
* continue to execute even after it was cancelled from the same thread in the case when the continuation
215+
* was already resumed and was posted for execution to the thread's queue.
228216
*/
229-
internal suspend inline fun <T> suspendAtomicCancellableCoroutineReusable(
230-
resumeMode: Int = MODE_ATOMIC_DEFAULT,
217+
internal suspend inline fun <T> suspendCancellableCoroutineReusable(
218+
resumeMode: Int,
231219
crossinline block: (CancellableContinuation<T>) -> Unit
232220
): T = suspendCoroutineUninterceptedOrReturn { uCont ->
233221
val cancellable = getOrCreateCancellableContinuation(uCont.intercepted(), resumeMode)
@@ -238,6 +226,7 @@ internal suspend inline fun <T> suspendAtomicCancellableCoroutineReusable(
238226
internal fun <T> getOrCreateCancellableContinuation(
239227
delegate: Continuation<T>, resumeMode: Int
240228
): CancellableContinuationImpl<T> {
229+
assert { resumeMode.isReusableMode }
241230
// If used outside of our dispatcher
242231
if (delegate !is DispatchedContinuation<T>) {
243232
return CancellableContinuationImpl(delegate, resumeMode)
@@ -260,20 +249,6 @@ internal fun <T> getOrCreateCancellableContinuation(
260249
?: return CancellableContinuationImpl(delegate, resumeMode)
261250
}
262251

263-
/**
264-
* @suppress **Deprecated**
265-
*/
266-
@Deprecated(
267-
message = "holdCancellability parameter is deprecated and is no longer used",
268-
replaceWith = ReplaceWith("suspendAtomicCancellableCoroutine(block)")
269-
)
270-
@InternalCoroutinesApi
271-
public suspend inline fun <T> suspendAtomicCancellableCoroutine(
272-
holdCancellability: Boolean = false,
273-
crossinline block: (CancellableContinuation<T>) -> Unit
274-
): T =
275-
suspendAtomicCancellableCoroutine(block)
276-
277252
/**
278253
* Removes the specified [node] on cancellation.
279254
*/

kotlinx-coroutines-core/common/src/CancellableContinuationImpl.kt

+5-5
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,8 @@ internal open class CancellableContinuationImpl<in T>(
8888
private fun isReusable(): Boolean = delegate is DispatchedContinuation<*> && delegate.isReusable(this)
8989

9090
/**
91-
* Resets cancellability state in order to [suspendAtomicCancellableCoroutineReusable] to work.
92-
* Invariant: used only by [suspendAtomicCancellableCoroutineReusable] in [REUSABLE_CLAIMED] state.
91+
* Resets cancellability state in order to [suspendCancellableCoroutineReusable] to work.
92+
* Invariant: used only by [suspendCancellableCoroutineReusable] in [REUSABLE_CLAIMED] state.
9393
*/
9494
@JvmName("resetState") // Prettier stack traces
9595
internal fun resetState(resumeMode: Int): Boolean {
@@ -174,7 +174,7 @@ internal open class CancellableContinuationImpl<in T>(
174174
if (state is CancelHandler) invokeHandlerSafely { state.invoke(cause) }
175175
// Complete state update
176176
detachChildIfNonResuable()
177-
dispatchResume(mode = MODE_ATOMIC_DEFAULT)
177+
dispatchResume(mode = MODE_ATOMIC) // no need for additional cancellation checks
178178
return true
179179
}
180180
}
@@ -232,10 +232,10 @@ internal open class CancellableContinuationImpl<in T>(
232232
val state = this.state
233233
if (state is CompletedExceptionally) throw recoverStackTrace(state.cause, this)
234234
// if the parent job was already cancelled, then throw the corresponding cancellation exception
235-
// otherwise, there is a race is suspendCancellableCoroutine { cont -> ... } does cont.resume(...)
235+
// otherwise, there is a race if suspendCancellableCoroutine { cont -> ... } does cont.resume(...)
236236
// before the block returns. This getResult would return a result as opposed to cancellation
237237
// exception that should have happened if the continuation is dispatched for execution later.
238-
if (resumeMode == MODE_CANCELLABLE) {
238+
if (resumeMode.isCancellableMode) {
239239
val job = context[Job]
240240
if (job != null && !job.isActive) {
241241
val cause = job.getCancellationException()

kotlinx-coroutines-core/common/src/channels/AbstractChannel.kt

+6-6
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ internal abstract class AbstractSendChannel<E> : SendChannel<E> {
165165
return closed.sendException
166166
}
167167

168-
private suspend fun sendSuspend(element: E): Unit = suspendAtomicCancellableCoroutineReusable sc@ { cont ->
168+
private suspend fun sendSuspend(element: E): Unit = suspendCancellableCoroutineReusable(MODE_ATOMIC_REUSABLE) sc@ { cont ->
169169
loop@ while (true) {
170170
if (isFullImpl) {
171171
val send = SendElement(element, cont)
@@ -543,13 +543,13 @@ internal abstract class AbstractChannel<E> : AbstractSendChannel<E>(), Channel<E
543543
@Suppress("UNCHECKED_CAST")
544544
if (result !== POLL_FAILED && result !is Closed<*>) return result as E
545545
// slow-path does suspend
546-
return receiveSuspend(RECEIVE_THROWS_ON_CLOSE, MODE_ATOMIC_DEFAULT)
546+
return receiveSuspend(RECEIVE_THROWS_ON_CLOSE, MODE_ATOMIC_REUSABLE)
547547
}
548548

549549
@Suppress("UNCHECKED_CAST")
550550
private suspend fun <R> receiveSuspend(
551551
receiveMode: Int, resumeMode: Int
552-
): R = suspendAtomicCancellableCoroutineReusable(resumeMode) sc@ { cont ->
552+
): R = suspendCancellableCoroutineReusable(resumeMode) sc@ { cont ->
553553
val receive = ReceiveElement<E>(cont as CancellableContinuation<Any?>, receiveMode)
554554
while (true) {
555555
if (enqueueReceive(receive)) {
@@ -583,7 +583,7 @@ internal abstract class AbstractChannel<E> : AbstractSendChannel<E>(), Channel<E
583583
@Suppress("UNCHECKED_CAST")
584584
if (result !== POLL_FAILED && result !is Closed<*>) return result as E
585585
// slow-path does suspend
586-
return receiveSuspend(RECEIVE_NULL_ON_CLOSE, MODE_ATOMIC_DEFAULT)
586+
return receiveSuspend(RECEIVE_NULL_ON_CLOSE, MODE_ATOMIC_REUSABLE)
587587
}
588588

589589
@Suppress("UNCHECKED_CAST")
@@ -601,7 +601,7 @@ internal abstract class AbstractChannel<E> : AbstractSendChannel<E>(), Channel<E
601601
val result = pollInternal()
602602
if (result !== POLL_FAILED) return result.toResult()
603603
// slow-path does suspend
604-
return receiveSuspend(RECEIVE_RESULT, if (atomic) MODE_ATOMIC_DEFAULT else MODE_CANCELLABLE_REUSABLE)
604+
return receiveSuspend(RECEIVE_RESULT, if (atomic) MODE_ATOMIC_REUSABLE else MODE_CANCELLABLE_REUSABLE)
605605
}
606606

607607
@Suppress("UNCHECKED_CAST")
@@ -816,7 +816,7 @@ internal abstract class AbstractChannel<E> : AbstractSendChannel<E>(), Channel<E
816816
return true
817817
}
818818

819-
private suspend fun hasNextSuspend(): Boolean = suspendAtomicCancellableCoroutineReusable sc@ { cont ->
819+
private suspend fun hasNextSuspend(): Boolean = suspendCancellableCoroutineReusable(MODE_ATOMIC_REUSABLE) sc@ { cont ->
820820
val receive = ReceiveHasNext(this, cont)
821821
while (true) {
822822
if (channel.enqueueReceive(receive)) {

kotlinx-coroutines-core/common/src/internal/DispatchedContinuation.kt

+5-5
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ internal val REUSABLE_CLAIMED = Symbol("REUSABLE_CLAIMED")
1919
internal class DispatchedContinuation<in T>(
2020
@JvmField val dispatcher: CoroutineDispatcher,
2121
@JvmField val continuation: Continuation<T>
22-
) : DispatchedTask<T>(MODE_ATOMIC_DEFAULT), CoroutineStackFrame, Continuation<T> by continuation {
22+
) : DispatchedTask<T>(MODE_ATOMIC), CoroutineStackFrame, Continuation<T> by continuation {
2323
@JvmField
2424
@Suppress("PropertyName")
2525
internal var _state: Any? = UNDEFINED
@@ -43,7 +43,7 @@ internal class DispatchedContinuation<in T>(
4343
* }
4444
* // state == CC
4545
* ```
46-
* 4) [Throwable] continuation was cancelled with this cause while being in [suspendAtomicCancellableCoroutineReusable],
46+
* 4) [Throwable] continuation was cancelled with this cause while being in [suspendCancellableCoroutineReusable],
4747
* [CancellableContinuationImpl.getResult] will check for cancellation later.
4848
*
4949
* [REUSABLE_CLAIMED] state is required to prevent the lost resume in the channel.
@@ -83,7 +83,7 @@ internal class DispatchedContinuation<in T>(
8383
}
8484

8585
/**
86-
* Claims the continuation for [suspendAtomicCancellableCoroutineReusable] block,
86+
* Claims the continuation for [suspendCancellableCoroutineReusable] block,
8787
* so all cancellations will be postponed.
8888
*/
8989
@Suppress("UNCHECKED_CAST")
@@ -180,10 +180,10 @@ internal class DispatchedContinuation<in T>(
180180
val state = result.toState()
181181
if (dispatcher.isDispatchNeeded(context)) {
182182
_state = state
183-
resumeMode = MODE_ATOMIC_DEFAULT
183+
resumeMode = MODE_ATOMIC
184184
dispatcher.dispatch(context, this)
185185
} else {
186-
executeUnconfined(state, MODE_ATOMIC_DEFAULT) {
186+
executeUnconfined(state, MODE_ATOMIC) {
187187
withCoroutineContext(this.context, countOrElement) {
188188
continuation.resumeWith(result)
189189
}

kotlinx-coroutines-core/common/src/internal/DispatchedTask.kt

+36-9
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,44 @@ import kotlinx.coroutines.internal.*
88
import kotlin.coroutines.*
99
import kotlin.jvm.*
1010

11+
/**
12+
* Non-cancellable dispatch mode.
13+
*
14+
* **DO NOT CHANGE THE CONSTANT VALUE**. It might be inlined into legacy user code that was calling
15+
* inline `suspendAtomicCancellableCoroutine` function and did not support reuse.
16+
*/
17+
internal const val MODE_ATOMIC = 0
18+
19+
/**
20+
* Cancellable dispatch mode. It is used by user-facing [suspendCancellableCoroutine].
21+
* Note, that implementation of cancellability checks mode via [Int.isCancellableMode] extension.
22+
*
23+
* **DO NOT CHANGE THE CONSTANT VALUE**. It is being into the user code from [suspendCancellableCoroutine].
24+
*/
1125
@PublishedApi
12-
internal const val MODE_ATOMIC_DEFAULT = 0 // schedule non-cancellable dispatch for suspendCoroutine
13-
@PublishedApi
14-
internal const val MODE_CANCELLABLE = 1 // schedule cancellable dispatch for suspendCancellableCoroutine
26+
internal const val MODE_CANCELLABLE = 1
1527

16-
internal const val MODE_CANCELLABLE_REUSABLE = 2 // same as MODE_CANCELLABLE but supports reused
17-
internal const val MODE_UNDISPATCHED = 3 // when the thread is right, but need to mark it with current coroutine
28+
/**
29+
* Atomic dispatch mode for [suspendCancellableCoroutineReusable].
30+
* Note, that implementation of reuse checks mode via [Int.isReusableMode] extension.
31+
*/
32+
internal const val MODE_ATOMIC_REUSABLE = 2
33+
34+
/**
35+
* Cancellable dispatch mode for [suspendCancellableCoroutineReusable].
36+
* Note, that implementation of cancellability checks mode via [Int.isCancellableMode] extension;
37+
* implementation of reuse checks mode via [Int.isReusableMode] extension.
38+
*/
39+
internal const val MODE_CANCELLABLE_REUSABLE = 3
40+
41+
/**
42+
* Undispatched mode for [CancellableContinuation.resumeUndispatched].
43+
* It is used when the thread is right, but it needs to be mark it with the current coroutine.
44+
*/
45+
internal const val MODE_UNDISPATCHED = 4
1846

1947
internal val Int.isCancellableMode get() = this == MODE_CANCELLABLE || this == MODE_CANCELLABLE_REUSABLE
20-
internal val Int.isDispatchedMode get() = this != MODE_UNDISPATCHED
21-
internal val Int.isReusableMode get() = this == MODE_ATOMIC_DEFAULT || this == MODE_CANCELLABLE_REUSABLE
48+
internal val Int.isReusableMode get() = this == MODE_ATOMIC_REUSABLE || this == MODE_CANCELLABLE_REUSABLE
2249

2350
internal abstract class DispatchedTask<in T>(
2451
@JvmField public var resumeMode: Int
@@ -103,7 +130,7 @@ internal abstract class DispatchedTask<in T>(
103130

104131
internal fun <T> DispatchedTask<T>.dispatch(mode: Int) {
105132
val delegate = this.delegate
106-
if (mode.isDispatchedMode && delegate is DispatchedContinuation<*> && mode.isCancellableMode == resumeMode.isCancellableMode) {
133+
if (mode != MODE_UNDISPATCHED && delegate is DispatchedContinuation<*> && mode.isCancellableMode == resumeMode.isCancellableMode) {
107134
// dispatch directly using this instance's Runnable implementation
108135
val dispatcher = delegate.dispatcher
109136
val context = delegate.context
@@ -124,7 +151,7 @@ internal fun <T> DispatchedTask<T>.resume(delegate: Continuation<T>, useMode: In
124151
val exception = getExceptionalResult(state)?.let { recoverStackTrace(it, delegate) }
125152
val result = if (exception != null) Result.failure(exception) else Result.success(state as T)
126153
when (useMode) {
127-
MODE_ATOMIC_DEFAULT -> delegate.resumeWith(result)
154+
MODE_ATOMIC, MODE_ATOMIC_REUSABLE -> delegate.resumeWith(result)
128155
MODE_CANCELLABLE, MODE_CANCELLABLE_REUSABLE -> delegate.resumeCancellableWith(result)
129156
MODE_UNDISPATCHED -> (delegate as DispatchedContinuation).resumeUndispatchedWith(result)
130157
else -> error("Invalid mode $useMode")

kotlinx-coroutines-core/common/src/sync/Mutex.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ internal class MutexImpl(locked: Boolean) : Mutex, SelectClause2<Any?, Mutex> {
188188
return lockSuspend(owner)
189189
}
190190

191-
private suspend fun lockSuspend(owner: Any?) = suspendAtomicCancellableCoroutineReusable<Unit> sc@ { cont ->
191+
private suspend fun lockSuspend(owner: Any?) = suspendCancellableCoroutineReusable<Unit>(MODE_ATOMIC_REUSABLE) sc@ { cont ->
192192
val waiter = LockCont(owner, cont)
193193
_state.loop { state ->
194194
when (state) {

kotlinx-coroutines-core/common/src/sync/Semaphore.kt

+1-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import kotlinx.atomicfu.*
88
import kotlinx.coroutines.*
99
import kotlinx.coroutines.internal.*
1010
import kotlin.coroutines.*
11-
import kotlin.jvm.*
1211
import kotlin.math.*
1312
import kotlin.native.concurrent.*
1413

@@ -136,7 +135,7 @@ private class SemaphoreImpl(
136135
cur + 1
137136
}
138137

139-
private suspend fun addToQueueAndSuspend() = suspendAtomicCancellableCoroutineReusable<Unit> sc@ { cont ->
138+
private suspend fun addToQueueAndSuspend() = suspendCancellableCoroutineReusable<Unit>(MODE_ATOMIC_REUSABLE) sc@ { cont ->
140139
val last = this.tail
141140
val enqIdx = enqIdx.getAndIncrement()
142141
val segment = getSegment(last, enqIdx / SEGMENT_SIZE)

kotlinx-coroutines-core/jvm/test/JobStructuredJoinStressTest.kt

+36-5
Original file line numberDiff line numberDiff line change
@@ -5,29 +5,60 @@
55
package kotlinx.coroutines
66

77
import org.junit.*
8+
import kotlin.coroutines.*
89

910
/**
1011
* Test a race between job failure and join.
1112
*
1213
* See [#1123](https://github.com/Kotlin/kotlinx.coroutines/issues/1123).
1314
*/
1415
class JobStructuredJoinStressTest : TestBase() {
15-
private val nRepeats = 1_000 * stressTestMultiplier
16+
private val nRepeats = 10_000 * stressTestMultiplier
1617

1718
@Test
18-
fun testStress() {
19-
repeat(nRepeats) {
19+
fun testStressRegularJoin() {
20+
stress(Job::join)
21+
}
22+
23+
@Test
24+
fun testStressSuspendCancellable() {
25+
stress { job ->
26+
suspendCancellableCoroutine { cont ->
27+
job.invokeOnCompletion { cont.resume(Unit) }
28+
}
29+
}
30+
}
31+
32+
@Test
33+
fun testStressSuspendCancellableReusable() {
34+
stress { job ->
35+
suspendCancellableCoroutineReusable(MODE_CANCELLABLE_REUSABLE) { cont ->
36+
job.invokeOnCompletion { cont.resume(Unit) }
37+
}
38+
}
39+
}
40+
41+
private fun stress(join: suspend (Job) -> Unit) {
42+
expect(1)
43+
repeat(nRepeats) { index ->
2044
assertFailsWith<TestException> {
2145
runBlocking {
2246
// launch in background
2347
val job = launch(Dispatchers.Default) {
2448
throw TestException("OK") // crash
2549
}
26-
assertFailsWith<CancellationException> {
27-
job.join()
50+
try {
51+
join(job)
52+
error("Should not complete successfully")
53+
} catch (e: CancellationException) {
54+
// must always crash with cancellation exception
55+
expect(2 + index)
56+
} catch (e: Throwable) {
57+
error("Unexpected exception", e)
2858
}
2959
}
3060
}
3161
}
62+
finish(2 + nRepeats)
3263
}
3364
}

0 commit comments

Comments
 (0)