Skip to content

Commit 94ebad0

Browse files
committed
Let event handlers report progress and see their own state
Ack gains feedback(), isResolved() and getStatus(). feedback("...") pushes a line into the status text of this event's row in the execution view, capped at 255 characters and ignored once the ack has settled; it is optional, and an event that never calls it behaves as before. Handlers now run against a budget: EventOptions.timeoutMs (default 15s) after which the SDK resolves the ack as RESOLVED_TIMEOUT and tells the server the event failed, so a stalled handler no longer hangs the operator's row. The budget is published with the action so the server can size its own watchdog around it rather than guess. Resolution happens exactly once, whichever of success, error or timeout gets there first, and every Ack method is safe to call from any thread.
1 parent f13c149 commit 94ebad0

4 files changed

Lines changed: 186 additions & 20 deletions

File tree

README.md

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ Rules:
9191
- Registering the same name again replaces the previous handler.
9292
- Events are published to server after adoption and whenever set changes.
9393
- `icon` is optional; omit it and the event shows the default `terminal` glyph.
94+
- A handler that has not called `ack.success()` / `ack.error()` within
95+
`timeoutMs` is resolved as `RESOLVED_TIMEOUT`. Do slow work on your own
96+
thread and report progress with `ack.feedback()` — see [Ack](#ack).
9497

9598
### unregisterEvent
9699

@@ -158,13 +161,15 @@ EventOptions(
158161
label = "Readable Label", // default: event name
159162
colour = 7, // 0..7, default 7
160163
hasFeedback = true, // default true
161-
icon = "circle-fill" // default "terminal"
164+
icon = "circle-fill", // default "terminal"
165+
timeoutMs = 15_000 // default 15_000
162166
)
163167
```
164168

165169
Validation:
166170
- `colour` must be in range `0..7`.
167171
- If `label` is provided, it must be non-blank and `<= 80` chars.
172+
- `timeoutMs` must be between `1_000` and `600_000`.
168173
- If `icon` is provided, it must be a [Bootstrap Icons](https://icons.getbootstrap.com)
169174
name: lowercase letters, digits and hyphens, `<= 64` chars. A `bi-` prefix is
170175
accepted and stripped, so `"bi-terminal"` and `"terminal"` are equivalent.
@@ -189,9 +194,54 @@ Validated inputs include:
189194
Provided to each event handler:
190195
- `ack.success()` reports successful completion.
191196
- `ack.error("reason")` reports failed completion.
197+
- `ack.feedback("message")` optionally reports progress while the handler runs.
198+
- `ack.isResolved()` is true once the ack has settled, however it settled.
199+
- `ack.getStatus()` returns the `AckStatus`.
192200

193201
For `hasFeedback = false`, ack calls are harmless no-ops.
194202

203+
Every method is safe to call from any thread, and every method is a no-op once
204+
the ack has resolved — a slow handler that finishes late cannot overwrite the
205+
result the timeout already reported.
206+
207+
#### Progress feedback
208+
209+
`ack.feedback("message")` replaces the status text on this event's row in
210+
ShowTrak's execution view, leaving the progress bar where it was. It is purely
211+
optional: an event that never calls it behaves exactly as it always has.
212+
213+
```kotlin
214+
ShowTrak.registerEvent(
215+
"RunDiagnostics",
216+
EventOptions(label = "Run Diagnostics", timeoutMs = 20_000)
217+
) { ack ->
218+
backgroundExecutor.execute { // never block the SDK's callback thread
219+
for (step in 1..5) {
220+
if (ack.isResolved()) return@execute // timed out from under us
221+
doStep(step)
222+
ack.feedback("Step $step of 5 complete")
223+
}
224+
ack.success()
225+
}
226+
}
227+
```
228+
229+
Messages are trimmed and capped at 255 characters (longer ones are cut, not
230+
rejected). Blank messages, calls after the ack has resolved, and calls on a
231+
`hasFeedback = false` event are all ignored.
232+
233+
#### AckStatus
234+
235+
- `UNRESOLVED` — still running.
236+
- `RESOLVED_SUCCESS``success()` was called.
237+
- `RESOLVED_ERROR``error()` was called, or the handler threw.
238+
- `RESOLVED_TIMEOUT` — the handler ran past `EventOptions.timeoutMs`.
239+
240+
A handler that has not resolved within `timeoutMs` is resolved for it, and the
241+
timeout is reported to the server so the operator sees the event fail rather
242+
than hang. The server arms its own watchdog a few seconds beyond `timeoutMs`,
243+
so an event whose device disappears mid-run still settles.
244+
195245
### SDKStatus
196246

197247
`SDKStatus` is an immutable snapshot returned by `ShowTrak.getStatus()`.

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ android.nonTransitiveRClass=true
55
kotlin.code.style=official
66

77
GROUP=io.github.showtrak
8-
VERSION_NAME=1.1.0
8+
VERSION_NAME=1.2.0
99
POM_ARTIFACT_ID=showtrak-sdk
1010
POM_NAME=ShowTrak Android SDK
1111
POM_DESCRIPTION=Reusable Android SDK for ShowTrak integrated clients.

showtrak-sdk/src/main/java/io/showtrak/sdk/Models.kt

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,15 @@ import java.util.Locale
1111
* @property icon Bootstrap Icons name shown beside the event, e.g. "lightning-charge-fill"
1212
* (browse them at https://icons.getbootstrap.com). The `bi-` prefix is
1313
* optional. Defaults to [DEFAULT_ICON] when omitted.
14+
* @property timeoutMs How long the handler may run before the SDK resolves its [Ack] as
15+
* [AckStatus.RESOLVED_TIMEOUT]. Raise it for genuinely slow work.
1416
*/
1517
data class EventOptions @JvmOverloads constructor(
1618
val label: String? = null,
1719
val colour: Int = 7,
1820
val hasFeedback: Boolean = true,
1921
val icon: String? = null,
22+
val timeoutMs: Long = DEFAULT_TIMEOUT_MS,
2023
) {
2124
init {
2225
require(colour in 0..7) { "EventOptions.colour must be between 0 and 7" }
@@ -35,6 +38,10 @@ data class EventOptions @JvmOverloads constructor(
3538
"lowercase letters, digits and hyphens (got '$icon')"
3639
}
3740
}
41+
require(timeoutMs in MIN_TIMEOUT_MS..MAX_TIMEOUT_MS) {
42+
"EventOptions.timeoutMs must be between $MIN_TIMEOUT_MS and $MAX_TIMEOUT_MS " +
43+
"(got $timeoutMs)"
44+
}
3845
}
3946

4047
/**
@@ -49,6 +56,14 @@ data class EventOptions @JvmOverloads constructor(
4956
/** Glyph used for events that declare no icon. Matches the server's default. */
5057
const val DEFAULT_ICON = "terminal"
5158

59+
/** Default handler budget, matching the server's default script timeout. */
60+
const val DEFAULT_TIMEOUT_MS = 15_000L
61+
62+
/** Longest progress message [Ack.feedback] will send; longer ones are cut. */
63+
const val MAX_FEEDBACK_LENGTH = 255
64+
65+
private const val MIN_TIMEOUT_MS = 1_000L
66+
private const val MAX_TIMEOUT_MS = 600_000L
5267
private const val MAX_ICON_LENGTH = 64
5368
private val ICON_PATTERN = Regex("^[a-z0-9-]+$")
5469

@@ -69,17 +84,59 @@ data class EventOptions @JvmOverloads constructor(
6984
}
7085
}
7186

87+
/** How an event handler's [Ack] finished, or that it has not finished yet. */
88+
enum class AckStatus {
89+
/** Still running: none of success, error or the timeout has fired. */
90+
UNRESOLVED,
91+
92+
/** [Ack.success] was called. */
93+
RESOLVED_SUCCESS,
94+
95+
/** [Ack.error] was called, or the handler threw. */
96+
RESOLVED_ERROR,
97+
98+
/** The handler ran past [EventOptions.timeoutMs] without resolving. */
99+
RESOLVED_TIMEOUT,
100+
}
101+
72102
/**
73103
* Passed to an event handler when an operator triggers the event. The handler
74104
* must call exactly one of [success] / [error] when it is done (for
75105
* [EventOptions.hasFeedback] events; for fire-and-forget events the calls are
76106
* harmless no-ops).
107+
*
108+
* A handler that has not resolved within [EventOptions.timeoutMs] is resolved
109+
* for it as [AckStatus.RESOLVED_TIMEOUT]. Every method here is safe to call
110+
* from any thread, and every one is a no-op once the ack has resolved — so a
111+
* slow handler that eventually finishes cannot overwrite the timeout result.
77112
*/
78113
interface Ack {
79114
val requestId: String
80115
val eventId: String
116+
117+
/** Report successful completion. No-op if already resolved. */
81118
fun success()
119+
120+
/** Report failure, with a reason shown to the operator. No-op if already resolved. */
82121
fun error(message: String)
122+
123+
/**
124+
* Optionally report progress while the handler runs: the message replaces
125+
* the status text on this event's row in ShowTrak's execution view, leaving
126+
* the progress bar alone. Entirely optional — an event that never calls it
127+
* behaves exactly as before.
128+
*
129+
* Messages are trimmed and capped at 255 characters (longer ones are cut,
130+
* not rejected). Blank messages, calls after the ack has resolved, and
131+
* calls on a `hasFeedback = false` event are all ignored.
132+
*/
133+
fun feedback(message: String)
134+
135+
/** True once this ack has resolved, by success, error or timeout. */
136+
fun isResolved(): Boolean
137+
138+
/** The current [AckStatus]. */
139+
fun getStatus(): AckStatus
83140
}
84141

85142
/** High-level connection lifecycle, surfaced for optional UI. */

showtrak-sdk/src/main/java/io/showtrak/sdk/ShowTrakClient.kt

Lines changed: 77 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,9 @@ class ShowTrakClient(context: Context) {
222222
// drawn for them anyway.
223223
.put("Icon", event.options.resolvedIcon)
224224
.put("HasFeedback", event.options.hasFeedback)
225+
// Lets the server size its own watchdog around this
226+
// handler's budget instead of guessing.
227+
.put("TimeoutMs", event.options.timeoutMs)
225228
)
226229
}
227230
s.emit("RegisterActions", array)
@@ -259,6 +262,75 @@ class ShowTrakClient(context: Context) {
259262
}, 0, 2, TimeUnit.SECONDS)
260263
}
261264

265+
/**
266+
* The [Ack] handed to one triggered event. Resolution happens exactly once,
267+
* whichever of success, error or the timeout gets there first, and every
268+
* method is callable from any thread.
269+
*/
270+
private inner class EventAck(
271+
override val requestId: String,
272+
override val eventId: String,
273+
private val options: EventOptions,
274+
) : Ack {
275+
276+
private val lock = Any()
277+
private var status: AckStatus = AckStatus.UNRESOLVED
278+
private var timeoutTask: ScheduledFuture<*>? = null
279+
280+
/** Start the handler's clock. Called before the handler runs. */
281+
fun arm() {
282+
val task = scheduler.schedule(
283+
{
284+
resolve(
285+
AckStatus.RESOLVED_TIMEOUT,
286+
"Handler did not respond within ${options.timeoutMs}ms"
287+
)
288+
},
289+
options.timeoutMs,
290+
TimeUnit.MILLISECONDS,
291+
)
292+
synchronized(lock) {
293+
// A handler that resolved before we got here must not leave a
294+
// timer running behind it.
295+
if (status != AckStatus.UNRESOLVED) task.cancel(false) else timeoutTask = task
296+
}
297+
}
298+
299+
override fun success() = resolve(AckStatus.RESOLVED_SUCCESS, null)
300+
301+
override fun error(message: String) =
302+
resolve(AckStatus.RESOLVED_ERROR, message.trim().ifEmpty { "Handler error" })
303+
304+
override fun isResolved(): Boolean = getStatus() != AckStatus.UNRESOLVED
305+
306+
override fun getStatus(): AckStatus = synchronized(lock) { status }
307+
308+
override fun feedback(message: String) {
309+
// Progress on an event nobody is waiting for has nowhere to go: the
310+
// server closed the row the moment it dispatched.
311+
if (!options.hasFeedback) return
312+
val text = message.trim()
313+
if (text.isEmpty()) return
314+
synchronized(lock) { if (status != AckStatus.UNRESOLVED) return }
315+
socket?.takeIf { it.connected() }?.emit(
316+
"IntegratedEventFeedback",
317+
requestId,
318+
text.take(EventOptions.MAX_FEEDBACK_LENGTH),
319+
)
320+
}
321+
322+
private fun resolve(newStatus: AckStatus, error: String?) {
323+
synchronized(lock) {
324+
if (status != AckStatus.UNRESOLVED) return
325+
status = newStatus
326+
timeoutTask?.cancel(false)
327+
timeoutTask = null
328+
}
329+
if (!options.hasFeedback) return
330+
socket?.emit("IntegratedEventResponse", requestId, error ?: JSONObject.NULL)
331+
}
332+
}
333+
262334
private fun handleTrigger(args: Array<out Any?>?) {
263335
val requestId = args?.getOrNull(0)?.toString() ?: return
264336
val eventId = args.getOrNull(1)?.toString() ?: return
@@ -267,24 +339,8 @@ class ShowTrakClient(context: Context) {
267339
socket?.emit("IntegratedEventResponse", requestId, "Event not registered")
268340
return
269341
}
270-
val ack = object : Ack {
271-
override val requestId = requestId
272-
override val eventId = eventId
273-
private var done = false
274-
override fun success() = complete(null)
275-
override fun error(message: String) = complete(message)
276-
private fun complete(error: String?) {
277-
if (done) return
278-
done = true
279-
if (event.options.hasFeedback) {
280-
socket?.emit(
281-
"IntegratedEventResponse",
282-
requestId,
283-
error ?: JSONObject.NULL
284-
)
285-
}
286-
}
287-
}
342+
val ack = EventAck(requestId, eventId, event.options)
343+
ack.arm()
288344
try {
289345
event.handler(ack)
290346
} catch (e: Exception) {
@@ -399,6 +455,9 @@ class ShowTrakClient(context: Context) {
399455
"Event '$eventId' icon must be a Bootstrap Icons name (got '$icon')"
400456
}
401457
}
458+
require(options.timeoutMs > 0) {
459+
"Event '$eventId' timeoutMs must be positive (got ${options.timeoutMs})"
460+
}
402461
}
403462

404463
private fun validatePort(port: Int) {

0 commit comments

Comments
 (0)