mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
feat: offer "send without PoW" when abandoning a mining job
Cancelling a proof-of-work mining job used to silently discard the post (the sign+broadcast continuation only ran on a successfully mined template). Users had no way to publish the note un-mined once they'd started waiting. Now abandoning a template post asks what to do instead of discarding: - The × on the mining banner opens a dialog with "Send without PoW", "Discard post", or tap-away to keep mining. Only shown for jobs that carry a plain un-mined fallback (template posts); opaque work jobs (reactions, reposts, anonymous posts, gift wraps) keep the direct cancel since they have no template to fall back to. - The mining foreground-service notification gains a "Send now" action that publishes every eligible queued post without proof of work. Implementation: the queue keeps the un-mined publish continuation alongside the miner. sendWithoutPow() sets a flag the worker picks up on its next isActive poll; the miner aborts and the plain template is published through the same sign+broadcast path the mined template would have used, off the worker pool. PoWJobState exposes canSendWithoutPow so the UI knows which jobs support it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012kLVFV7ps4HfXDDJPNqi82
This commit is contained in:
+55
-14
@@ -75,6 +75,20 @@ abstract class FlowProgressForegroundService<T> : Service() {
|
||||
/** The intent action that routes back here to cancel everything. */
|
||||
protected abstract val cancelAction: String
|
||||
protected abstract val cancelLabelRes: Int
|
||||
|
||||
/**
|
||||
* Optional second notification action (e.g. "send without PoW"). When
|
||||
* [secondaryAction] and [secondaryLabelRes] are both non-null, the button is
|
||||
* shown and tapping it routes back here to run [onSecondaryAction] without
|
||||
* stopping the service — the work keeps going and the card drains normally.
|
||||
*/
|
||||
protected open val secondaryAction: String? = null
|
||||
protected open val secondaryLabelRes: Int? = null
|
||||
|
||||
protected open fun onSecondaryAction() {
|
||||
// No-op by default: only subclasses that declare a secondary action override this.
|
||||
}
|
||||
|
||||
protected open val smallIcon: Int = R.drawable.amethyst
|
||||
|
||||
/** When non-null, re-render the card on this cadence (for clock-driven text like "time left"). */
|
||||
@@ -145,6 +159,17 @@ abstract class FlowProgressForegroundService<T> : Service() {
|
||||
)
|
||||
}
|
||||
|
||||
private val secondaryIntent: PendingIntent? by lazy {
|
||||
secondaryAction?.let { action ->
|
||||
PendingIntent.getService(
|
||||
this,
|
||||
2,
|
||||
Intent(this, this.javaClass).setAction(action),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(
|
||||
@@ -168,6 +193,14 @@ abstract class FlowProgressForegroundService<T> : Service() {
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
if (intent?.action != null && intent.action == secondaryAction) {
|
||||
// keep the service alive: the jobs flip to publishing and the watch
|
||||
// loop stops us once the queue drains.
|
||||
onSecondaryAction()
|
||||
watch()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
watch()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
@@ -252,20 +285,28 @@ abstract class FlowProgressForegroundService<T> : Service() {
|
||||
.setProgress(bar.done)
|
||||
}
|
||||
|
||||
return NotificationCompat
|
||||
.Builder(this, channelId)
|
||||
.setSmallIcon(smallIcon)
|
||||
.setContentTitle(content.title)
|
||||
.setContentText(content.text)
|
||||
.setStyle(style)
|
||||
.setContentIntent(tapIntent)
|
||||
.addAction(0, stringRes(this, cancelLabelRes), cancelIntent)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.build()
|
||||
val builder =
|
||||
NotificationCompat
|
||||
.Builder(this, channelId)
|
||||
.setSmallIcon(smallIcon)
|
||||
.setContentTitle(content.title)
|
||||
.setContentText(content.text)
|
||||
.setStyle(style)
|
||||
.setContentIntent(tapIntent)
|
||||
.addAction(0, stringRes(this, cancelLabelRes), cancelIntent)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
|
||||
val secondaryLabel = secondaryLabelRes
|
||||
val secondaryPending = secondaryIntent
|
||||
if (secondaryLabel != null && secondaryPending != null) {
|
||||
builder.addAction(0, stringRes(this, secondaryLabel), secondaryPending)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun ensureChannel() {
|
||||
|
||||
+5
@@ -56,6 +56,8 @@ class PowMiningForegroundService : FlowProgressForegroundService<ImmutableList<P
|
||||
override val notificationId = NOTIFICATION_ID
|
||||
override val cancelAction = ACTION_CANCEL_ALL
|
||||
override val cancelLabelRes = R.string.pow_notification_cancel_all
|
||||
override val secondaryAction = ACTION_SEND_ALL_NOW
|
||||
override val secondaryLabelRes = R.string.pow_notification_send_without_pow
|
||||
|
||||
// clock-driven refresh for the time-left text and bar; the shortService budget (~3 min)
|
||||
// caps this at a handful of updates.
|
||||
@@ -86,6 +88,8 @@ class PowMiningForegroundService : FlowProgressForegroundService<ImmutableList<P
|
||||
|
||||
override fun cancelAll() = Amethyst.instance.powPublishQueue.cancelAll()
|
||||
|
||||
override fun onSecondaryAction() = Amethyst.instance.powPublishQueue.sendAllWithoutPow()
|
||||
|
||||
override fun needsClockRefresh(value: ImmutableList<PoWJobState>) = value.any { it.isMining }
|
||||
|
||||
override fun onStarted() {
|
||||
@@ -147,6 +151,7 @@ class PowMiningForegroundService : FlowProgressForegroundService<ImmutableList<P
|
||||
private const val CHANNEL_ID = "pow_mining"
|
||||
private const val NOTIFICATION_ID = 0x504F57 // "POW"
|
||||
private const val ACTION_CANCEL_ALL = "com.vitorpamplona.amethyst.pow.CANCEL_ALL"
|
||||
private const val ACTION_SEND_ALL_NOW = "com.vitorpamplona.amethyst.pow.SEND_ALL_NOW"
|
||||
|
||||
private const val PROGRESS_REFRESH_MS = 30_000L
|
||||
|
||||
|
||||
@@ -44,15 +44,18 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -105,6 +108,7 @@ fun BroadcastBanner(
|
||||
broadcasts: ImmutableList<BroadcastEvent>,
|
||||
miningJobs: ImmutableList<PoWJobState> = persistentListOf(),
|
||||
onCancelJob: (String) -> Unit = {},
|
||||
onSendWithoutPow: (String) -> Unit = {},
|
||||
onTap: () -> Unit = {},
|
||||
onRetryAll: () -> Unit = {},
|
||||
onDismiss: () -> Unit = {},
|
||||
@@ -133,7 +137,7 @@ fun BroadcastBanner(
|
||||
.animateContentSize(),
|
||||
) {
|
||||
if (miningJobs.isNotEmpty()) {
|
||||
MiningContent(miningJobs, onCancelJob)
|
||||
MiningContent(miningJobs, onCancelJob, onSendWithoutPow)
|
||||
|
||||
if (broadcasts.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -166,7 +170,32 @@ fun BroadcastBanner(
|
||||
private fun MiningContent(
|
||||
miningJobs: ImmutableList<PoWJobState>,
|
||||
onCancelJob: (String) -> Unit,
|
||||
onSendWithoutPow: (String) -> Unit,
|
||||
) {
|
||||
// the job whose × was tapped and is awaiting the send-or-discard choice.
|
||||
var confirmJobId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// drop the dialog if its job finished (mined and published) while it was open.
|
||||
LaunchedEffect(miningJobs) {
|
||||
if (confirmJobId != null && miningJobs.none { it.id == confirmJobId }) {
|
||||
confirmJobId = null
|
||||
}
|
||||
}
|
||||
|
||||
confirmJobId?.let { jobId ->
|
||||
PoWCancelDialog(
|
||||
onSendWithoutPow = {
|
||||
onSendWithoutPow(jobId)
|
||||
confirmJobId = null
|
||||
},
|
||||
onDiscard = {
|
||||
onCancelJob(jobId)
|
||||
confirmJobId = null
|
||||
},
|
||||
onKeepMining = { confirmJobId = null },
|
||||
)
|
||||
}
|
||||
|
||||
// one shared pulse for the gear — mining has no measurable progress, so
|
||||
// the animation is what says "the app is working right now".
|
||||
val pulse = rememberInfiniteTransition(label = "miningPulse")
|
||||
@@ -264,7 +293,16 @@ private fun MiningContent(
|
||||
// there is nothing safe to abort anymore.
|
||||
if (job.isCancellable) {
|
||||
IconButton(
|
||||
onClick = { onCancelJob(job.id) },
|
||||
// a template post can still go out un-mined, so ask first;
|
||||
// opaque jobs (reactions, reposts) have no such choice and
|
||||
// just cancel.
|
||||
onClick = {
|
||||
if (job.canSendWithoutPow) {
|
||||
confirmJobId = job.id
|
||||
} else {
|
||||
onCancelJob(job.id)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(22.dp),
|
||||
) {
|
||||
Icon(
|
||||
@@ -320,6 +358,37 @@ private fun MiningContent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks what to do with a post whose proof of work is still mining: publish it
|
||||
* now without PoW, discard it, or keep mining. Only shown for jobs that carry
|
||||
* an un-mined fallback (template posts).
|
||||
*/
|
||||
@Composable
|
||||
private fun PoWCancelDialog(
|
||||
onSendWithoutPow: () -> Unit,
|
||||
onDiscard: () -> Unit,
|
||||
onKeepMining: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onKeepMining,
|
||||
title = { Text(stringRes(R.string.pow_cancel_dialog_title)) },
|
||||
text = { Text(stringRes(R.string.pow_cancel_dialog_message)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onSendWithoutPow) {
|
||||
Text(stringRes(R.string.pow_cancel_dialog_send_without_pow))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDiscard) {
|
||||
Text(
|
||||
text = stringRes(R.string.pow_cancel_dialog_discard),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SingleBroadcastContent(broadcast: BroadcastEvent) {
|
||||
Row(
|
||||
|
||||
+1
@@ -132,6 +132,7 @@ fun DisplaySnack(
|
||||
broadcasts = activeBroadcasts,
|
||||
miningJobs = miningJobs,
|
||||
onCancelJob = { Amethyst.instance.powPublishQueue.cancel(it) },
|
||||
onSendWithoutPow = { Amethyst.instance.powPublishQueue.sendWithoutPow(it) },
|
||||
onTap = onTap,
|
||||
onRetryAll = {
|
||||
activeBroadcasts.forEach { b ->
|
||||
|
||||
@@ -4386,6 +4386,11 @@
|
||||
<string name="pow_notification_channel_name">Proof of work mining</string>
|
||||
<string name="pow_notification_channel_description">Shows posts that are still mining their NIP-13 proof of work so they can finish after you leave the app.</string>
|
||||
<string name="pow_notification_cancel_all">Cancel</string>
|
||||
<string name="pow_notification_send_without_pow">Send now</string>
|
||||
<string name="pow_cancel_dialog_title">Stop mining?</string>
|
||||
<string name="pow_cancel_dialog_message">This post is still mining its proof of work. You can send it now without proof of work, or discard it.</string>
|
||||
<string name="pow_cancel_dialog_send_without_pow">Send without PoW</string>
|
||||
<string name="pow_cancel_dialog_discard">Discard post</string>
|
||||
<string name="pow_difficulty_estimate">≈ %1$s per post on this device</string>
|
||||
<string name="pow_estimate_instant"><1 s</string>
|
||||
<plurals name="pow_estimate_seconds">
|
||||
|
||||
+69
-5
@@ -75,6 +75,13 @@ data class PoWJobState(
|
||||
val difficulty: Int,
|
||||
val phase: PoWJobPhase = PoWJobPhase.QUEUED,
|
||||
val miningStartedAt: Long? = null,
|
||||
/**
|
||||
* True when the job carries a plain, un-mined fallback the user can publish
|
||||
* right away instead of waiting for (or discarding) the nonce search — the
|
||||
* template send paths. Opaque work jobs (reactions, reposts, anonymous
|
||||
* posts, gift wraps) have no such fallback and are false.
|
||||
*/
|
||||
val canSendWithoutPow: Boolean = false,
|
||||
) {
|
||||
val isMining: Boolean get() = phase == PoWJobPhase.MINING
|
||||
val isCancellable: Boolean get() = phase != PoWJobPhase.PUBLISHING
|
||||
@@ -131,12 +138,19 @@ class PoWPublishQueue(
|
||||
val dedupeKey: String?,
|
||||
val owner: HexKey?,
|
||||
val work: suspend (isActive: () -> Boolean) -> Unit,
|
||||
// When non-null, publishes the event WITHOUT proof of work — the plain
|
||||
// template send path, run in place of the (abandoned) nonce search.
|
||||
val sendWithoutPow: (suspend () -> Unit)?,
|
||||
) {
|
||||
@Volatile
|
||||
var cancelled = false
|
||||
|
||||
@Volatile
|
||||
var publishing = false
|
||||
|
||||
// Set by cancel-and-send-now: stop the miner and publish [sendWithoutPow].
|
||||
@Volatile
|
||||
var sendUnminedRequested = false
|
||||
}
|
||||
|
||||
private val queue = Channel<MiningJob>(UNLIMITED)
|
||||
@@ -205,6 +219,16 @@ class PoWPublishQueue(
|
||||
PoWMiner.mine(toMine, pubKey, difficulty, minerThreads, isActive)
|
||||
},
|
||||
publish = onMined,
|
||||
// send-now fallback: the same template, minus the nonce the miner would
|
||||
// have added. created_at follows the mined path — refreshed to "now" for
|
||||
// ordinary posts, left intact for scheduled ones.
|
||||
sendWithoutPow = {
|
||||
if (refreshCreatedAtOnStart) {
|
||||
EventTemplate<T>(TimeUtils.now(), template.kind, template.tags, template.content)
|
||||
} else {
|
||||
template
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -222,6 +246,11 @@ class PoWPublishQueue(
|
||||
owner: HexKey? = null,
|
||||
mine: suspend (isActive: () -> Boolean) -> R,
|
||||
publish: suspend (R) -> Unit,
|
||||
// When non-null, produces the value [publish] would have received
|
||||
// WITHOUT mining, so the user can abandon the nonce search and send the
|
||||
// event right away (see [sendWithoutPow]). Null for stages whose result
|
||||
// only exists after mining (gift wraps).
|
||||
sendWithoutPow: (suspend () -> R)? = null,
|
||||
) {
|
||||
val id = persistAs?.id ?: RandomInstance.randomChars(16)
|
||||
addJob(
|
||||
@@ -231,6 +260,7 @@ class PoWPublishQueue(
|
||||
persistAs = persistAs,
|
||||
dedupeKey = dedupeKey,
|
||||
owner = owner ?: persistAs?.accountPubkey,
|
||||
sendWithoutPow = sendWithoutPow?.let { produce -> { publish(produce()) } },
|
||||
) { isActive ->
|
||||
val mined = mine(isActive)
|
||||
// frees the mining worker: signing may wait on an external signer
|
||||
@@ -257,7 +287,7 @@ class PoWPublishQueue(
|
||||
dedupeKey: String? = null,
|
||||
owner: HexKey? = null,
|
||||
work: suspend (isActive: () -> Boolean) -> Unit,
|
||||
) = addJob(RandomInstance.randomChars(16), kind, difficulty, persistAs = null, dedupeKey = dedupeKey, owner = owner, work = work)
|
||||
) = addJob(RandomInstance.randomChars(16), kind, difficulty, persistAs = null, dedupeKey = dedupeKey, owner = owner, sendWithoutPow = null, work = work)
|
||||
|
||||
private fun addJob(
|
||||
id: String,
|
||||
@@ -266,6 +296,7 @@ class PoWPublishQueue(
|
||||
persistAs: PersistedPoWJob?,
|
||||
dedupeKey: String?,
|
||||
owner: HexKey?,
|
||||
sendWithoutPow: (suspend () -> Unit)?,
|
||||
work: suspend (isActive: () -> Boolean) -> Unit,
|
||||
) {
|
||||
val current = pending.value
|
||||
@@ -278,10 +309,10 @@ class PoWPublishQueue(
|
||||
return
|
||||
}
|
||||
|
||||
val job = MiningJob(id, kind, difficulty, persisted = persistAs != null, dedupeKey = dedupeKey, owner = owner, work = work)
|
||||
val job = MiningJob(id, kind, difficulty, persisted = persistAs != null, dedupeKey = dedupeKey, owner = owner, work = work, sendWithoutPow = sendWithoutPow)
|
||||
persistAs?.let { persistence?.save(it) }
|
||||
pending.update { it.putting(job.id, job) }
|
||||
_jobs.update { (it + PoWJobState(job.id, job.kind, job.difficulty)).toImmutableList() }
|
||||
_jobs.update { (it + PoWJobState(job.id, job.kind, job.difficulty, canSendWithoutPow = sendWithoutPow != null)).toImmutableList() }
|
||||
Log.d(TAG) {
|
||||
val durability = if (persistAs != null) "persisted" else "in-memory only, lost on process death"
|
||||
"Enqueued PoW job ${job.id} kind=${job.kind} difficulty=${job.difficulty} ($durability)"
|
||||
@@ -319,6 +350,28 @@ class PoWPublishQueue(
|
||||
pending.value.keys.forEach { cancel(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Abandons the nonce search for [jobId] and publishes its event immediately
|
||||
* WITHOUT proof of work, through the same sign+broadcast continuation the
|
||||
* mined template would have used. No-op if the job already finished, is
|
||||
* publishing, was cancelled, or was enqueued without an un-mined fallback
|
||||
* (opaque work jobs — reactions, reposts, anonymous posts, gift wraps — have
|
||||
* no plain template to fall back to). The running worker picks up the flag
|
||||
* on its next [isActive] poll and does the publish; a still-QUEUED job does
|
||||
* it as soon as a worker takes it.
|
||||
*/
|
||||
fun sendWithoutPow(jobId: String) {
|
||||
val job = pending.value[jobId] ?: return
|
||||
if (job.publishing || job.cancelled || job.sendWithoutPow == null) return
|
||||
job.sendUnminedRequested = true
|
||||
Log.d(TAG) { "Requested send-without-PoW for job $jobId" }
|
||||
}
|
||||
|
||||
/** Sends every job that supports it without proof of work; others keep mining. */
|
||||
fun sendAllWithoutPow() {
|
||||
pending.value.keys.forEach { sendWithoutPow(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels every queued/mining job enqueued for [owner]'s account — used at
|
||||
* log-off so a deleted account's posts can't publish after the fact.
|
||||
@@ -342,13 +395,24 @@ class PoWPublishQueue(
|
||||
var detached = false
|
||||
|
||||
try {
|
||||
job.work { !job.cancelled && workerJob.isActive }
|
||||
job.work { !job.cancelled && !job.sendUnminedRequested && workerJob.isActive }
|
||||
detached = job.publishing
|
||||
Log.d(TAG) { "Finished mining PoW job ${job.id} kind=${job.kind} difficulty=${job.difficulty}" }
|
||||
} catch (e: CancellationException) {
|
||||
// the worker itself was cancelled (scope teardown): propagate.
|
||||
if (!currentCoroutineContext().isActive) throw e
|
||||
Log.d(TAG) { "PoW job ${job.id} cancelled while mining" }
|
||||
|
||||
val sendWithoutPow = job.sendWithoutPow
|
||||
if (job.sendUnminedRequested && !job.cancelled && sendWithoutPow != null) {
|
||||
// user chose "send now": abandon the nonce search and publish the
|
||||
// plain template through the same continuation, off the pool.
|
||||
Log.d(TAG) { "PoW job ${job.id} sending without proof of work" }
|
||||
setPhase(job.id, PoWJobPhase.PUBLISHING)
|
||||
detached = true
|
||||
scope.launch { finishDetached(job.id, job.kind, persisted = job.persisted, onMined = sendWithoutPow) }
|
||||
} else {
|
||||
Log.d(TAG) { "PoW job ${job.id} cancelled while mining" }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// mining-stage failures are deterministic (bad difficulty, broken
|
||||
// template): drop the checkpoint too, or every restore re-crashes.
|
||||
|
||||
+59
@@ -97,6 +97,65 @@ class PoWPublishQueueTest {
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendWithoutPowPublishesTheUnminedTemplate() =
|
||||
runTest {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val queue = PoWPublishQueue(scope, maxConcurrent = 1)
|
||||
val sent = CompletableDeferred<EventTemplate<TextNoteEvent>>()
|
||||
|
||||
// difficulty 40 would mine for ages: it must NOT finish before we
|
||||
// abandon it, so what publishes is guaranteed the un-mined template.
|
||||
queue.enqueue(template, pubKey, difficulty = 40) { sent.complete(it) }
|
||||
|
||||
// wait until a worker is actually searching, then send now.
|
||||
withContext(Dispatchers.Default) { withTimeout(10_000) { queue.jobs.first { it.any { j -> j.isMining } } } }
|
||||
val jobId =
|
||||
queue.jobs.value
|
||||
.first()
|
||||
.id
|
||||
assertTrue(
|
||||
queue.jobs.value
|
||||
.first()
|
||||
.canSendWithoutPow,
|
||||
"template jobs offer the un-mined fallback",
|
||||
)
|
||||
|
||||
queue.sendWithoutPow(jobId)
|
||||
|
||||
val result = withContext(Dispatchers.Default) { withTimeout(10_000) { sent.await() } }
|
||||
val powTag = result.tags.firstNotNullOfOrNull { PoWTag.parse(it) }
|
||||
assertEquals(null, powTag, "an un-mined send must carry no nonce tag")
|
||||
|
||||
withContext(Dispatchers.Default) { withTimeout(10_000) { queue.jobs.first { it.isEmpty() } } }
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendWithoutPowIsNoOpForOpaqueWorkJobs() =
|
||||
runTest {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val queue = PoWPublishQueue(scope, maxConcurrent = 1)
|
||||
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
var ran = false
|
||||
// occupies the worker so the second job stays QUEUED and observable
|
||||
queue.enqueueWork(kind = 1, difficulty = 10) { gate.await() }
|
||||
queue.enqueueWork(kind = 1, difficulty = 10) { ran = true }
|
||||
|
||||
val workJob = queue.jobs.value[1]
|
||||
assertFalse(workJob.canSendWithoutPow, "opaque work jobs have no un-mined fallback")
|
||||
|
||||
// no fallback → the request is ignored, the job keeps its place.
|
||||
queue.sendWithoutPow(workJob.id)
|
||||
assertEquals(2, queue.jobs.value.size)
|
||||
|
||||
gate.complete(Unit)
|
||||
withContext(Dispatchers.Default) { withTimeout(10_000) { queue.jobs.first { it.isEmpty() } } }
|
||||
assertTrue(ran, "the work job still runs its normal mining path")
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun jobsRunInFifoOrder() =
|
||||
runTest {
|
||||
|
||||
Reference in New Issue
Block a user