Leaving the import screen used to cancel the batch it had started: the
request was the work, so the coroutine that owned it died with the screen
and coming back showed no sign anything had happened. A half-imported
session is the expensive kind of missing -- the row is back looking
untouched, and taking it again is the second `--resume` the import path
exists to prevent.
So the work runs on the server now. Delete and a new per-session import both
answer 202 and spawn the work, and `session::pending` is the record of it:
what is running, and how the last attempt failed. The phone reads that two
ways and needs both. Every row of the listing carries `pending` and `error`,
which is what a phone that was asleep, out of range or freshly opened has to
go on; `GET /setups/{id}/importable/events` streams the changes, which is
what makes a screen somebody is watching change by itself.
Neither alone is enough, and that is not theoretical. A broadcast has no
memory, so an operation that started and finished while the stream was still
connecting was one nothing would ever be said about -- with responses held
back far enough to make it visible, one row of a pair of deletes cleared and
the other sat on "waiting" for good. The screen now asks again after a
handover when anything still looks outstanding, and takes its row states
from that answer rather than from what it remembers.
The single tap still waits, because "take me to it" needs the session that
was made and 202 does not carry one. Both paths go through the same `spawn`
so they cannot drift about what importing means.
Resolving one importable session no longer lists every one of them:
`import::find` is the same script with one glob narrower, which takes the
import seed off the 3.7-second full scan that `delete` came off earlier.
The SSE connection and its framing are now `Sse`, shared with the session
transcript stream rather than written a second time.
98 lines
4.4 KiB
Kotlin
98 lines
4.4 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import java.io.IOException
|
|
import java.net.HttpURLConnection
|
|
import java.net.URL
|
|
|
|
/**
|
|
* How long to wait before opening a dropped stream again.
|
|
*
|
|
* Shared by every screen that follows one, so a reconnect is not paced differently depending on
|
|
* which stream dropped. Short enough that a tunnel coming back is not noticed, long enough that a
|
|
* server which is genuinely down is not being asked several times a second.
|
|
*/
|
|
const val RECONNECT_DELAY_MS = 1500L
|
|
|
|
/**
|
|
* One server-sent-events connection, framed.
|
|
*
|
|
* The framing is the part worth having once: `data:` and `event:` lines accumulate until a blank
|
|
* line ends the frame, comments (keep-alives) start with `:`, and a frame is either named with no
|
|
* payload or a payload with no name. Two screens follow two different streams — a session's
|
|
* transcript and what a machine's import list is doing — and neither should be re-deriving that.
|
|
*
|
|
* Blocking: [run] occupies its thread until the stream ends. [close], from any thread, is the
|
|
* cancellation path — it disconnects the socket, which unblocks the read, and [run] then returns
|
|
* rather than throwing, so a deliberate close is not reported as a connection error. Reconnecting
|
|
* belongs to the caller, which is the only one that knows where to resume from.
|
|
*/
|
|
class Sse(private val settings: ServerSettings) {
|
|
@Volatile private var connection: HttpURLConnection? = null
|
|
@Volatile private var closed = false
|
|
|
|
fun close() {
|
|
closed = true
|
|
connection?.disconnect()
|
|
}
|
|
|
|
/**
|
|
* Follows the stream at [path], handing each frame to [onFrame] as its name (null for an
|
|
* ordinary data frame) and its payload. The path is given here rather than at construction
|
|
* because a caller that reconnects usually resumes from somewhere new -- a cursor it has
|
|
* advanced past -- and that lives in the query string.
|
|
*
|
|
* [onOpen] fires once the server has accepted the connection. That is the measured moment the
|
|
* stream is live, and the only honest thing to clear a previous failure on: clearing on the
|
|
* first *event* instead left an idle stream displaying a connection error it had already
|
|
* recovered from, indefinitely.
|
|
*/
|
|
fun run(path: String, onOpen: () -> Unit, onFrame: (name: String?, data: String) -> Unit) {
|
|
val connection = URL("${settings.baseUrl}$path").openConnection() as HttpURLConnection
|
|
this.connection = connection
|
|
try {
|
|
connection.applyPinnedTls()
|
|
connection.connectTimeout = CONNECT_TIMEOUT_MS
|
|
// No read timeout: between events there is nothing to read for as long as the thing
|
|
// being followed is idle; the server's keep-alives and a dead socket erroring out are
|
|
// the liveness story.
|
|
connection.readTimeout = 0
|
|
connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
|
|
connection.setRequestProperty("Accept", "text/event-stream")
|
|
if (connection.responseCode != 200) {
|
|
val detail = connection.errorStream?.bufferedReader()?.readText()?.trim()
|
|
throw ApiException(detail ?: "HTTP ${connection.responseCode} for the event stream")
|
|
}
|
|
|
|
onOpen()
|
|
val reader = connection.inputStream.bufferedReader()
|
|
val data = StringBuilder()
|
|
var name: String? = null
|
|
while (true) {
|
|
val line = reader.readLine() ?: break
|
|
when {
|
|
line.isEmpty() -> {
|
|
if (name != null || data.isNotEmpty()) onFrame(name, data.toString())
|
|
data.clear()
|
|
name = null
|
|
}
|
|
line.startsWith("data:") -> data.append(line.removePrefix("data:").trim())
|
|
line.startsWith("event:") -> name = line.removePrefix("event:").trim()
|
|
else -> {} // id:, comments -- nothing to do
|
|
}
|
|
}
|
|
} catch (e: ApiException) {
|
|
throw e
|
|
} catch (e: IOException) {
|
|
if (!closed) {
|
|
throw ApiException(
|
|
"Can't reach the server -- retrying. (${e.message ?: e::class.simpleName})",
|
|
e,
|
|
)
|
|
}
|
|
} finally {
|
|
connection.disconnect()
|
|
this.connection = null
|
|
}
|
|
}
|
|
}
|