I am trying to to wrap my head around what the "proper" structure of a CLI app should be that runs async/await stuff but wants to keep an interactive UI (ie: reading keyboard input).
It kind of boils down to mixing blocking I/O with structured concurrency, but with the spicy extra bit of main-thread-runloop-mainactor-task-deadlock fun.
I could not find any guidance or pre-made solutions on the forums or github, and even vapor's console-kit appears to do the naughty of "blocking a concurrency runtime thread" in their async example.
While playing around I stumbled over this, which teaches me that I know even less than I thought:
(macOS ventura, swift 5.8.1)
Foo struct with async function
struct Foo {
func doStuff() async {
do {
print("starting inner loop")
var i = 1
while true {
try await Task.sleep(for: .seconds(1))
print("Printing \(i)")
i += 1
}
} catch {
print("inner loop goes brrr")
}
}
}
If I naively run this, things do not work (as in "do not run concurrently", which I expected):
@main
enum App {
static func main() async {
print("q to exit")
let run = Task { await Foo().doStuff() }
while let line = readLine() {
if line == "q" {
run.cancel()
break
}
}
await run.value
}
}
My intuition was that the Task captures the MainActor context, but the readLine
blocks the main thread, so doStuff
never gets scheduled.
However, when running this things DO work (ie: run concurrently, while blocking on readLine)
@main
enum App {
static func main() async {
print("q to exit")
let foo = Foo() //the only change, Foo is initialized outside the task
let run = Task { await foo.doStuff() }
while let line = readLine() {
if line == "q" {
run.cancel()
break
}
}
await run.value
}
}
Why is this working? (If doStuff is a free function it also works)
Why is the first one actually not working then?
And on the original assignment:
- Is it ever safe to use
readLine
(without some dispatch/background thread magic to get it off the concurrency runtime) in an async CLI app? - Are there existing, more sophisticated "read console input" methods that do not block the current thread?
- I could just kick off
doStuff
in aTask.detached
and keep the blocking on the main loop - is that a good idea? - I could leave the main "non-async", but the MainActor deadlock remains just the same (see below), and I would need some other hand-made signaling to wait for the background work. Does not sound like a better option, does it?
- I could make an async version of
readLine
that runs "somewhere else" (maybe even a fancy actor with a dispatch queue executor that "hosts" the interactive frontend?) and await it all in a nice TaskGroup on the main task - but that feels quite elaborate (and I do not really know what willy-nilly readLining from background threads does.) I kind of like this one, to be honest... Ideas?
(sync main also does not work)
@main
enum App {
static func main() {
print("q to exit")
let run = Task { await Foo().doStuff() }
while let line = readLine() {
if line == "q" {
run.cancel()
break
}
}
}
}