CLI app: readLine and structured concurrency

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 a Task.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
            }
        }
    }
}
1 Like

How is Foo declared? That will determine where await foo.doStuff() runs. Remember, the fundamental rule of Swift concurrency is "callee determines context", where "callee" is the awaited function. In this case, barring any other actor context like @MainActor, any async work Foo does will execute on default executor. So if we walk through that part of your example:

// On MainActor.
let run = Task { // Task is scheduled on MainActor.
  // On MainActor.
  await foo.doStuff() // doStuff() executes on default executor, Task waits.
  // On MainActor.
}

However, none of this explains why your first example fails. We'll have to investigate more for that.

Your first example runs fine for me, though you can see that the Task doesn't execute until after you break out of the while loop. Even in that case cancellation still works.

The Foo struct is collapsed in the original post (just a plain struct, no funny business)

Yeah, this seems to work fine, it just doesn't run until the while loop is complete. Making it a Task.detached lets the Task start while readLine is blocking.

agreed, makes sense, totally expected. but why is the second example running concurrently (without detaching) - that is what surprised me.

I see no difference in behavior for that form, as expected. What are you actually seeing? i.e. What does "work" mean for you there?

good point, let me clarify:

the intention is to run async code (doing whatever) while also keeping an interactive CLI with keyboard input going. so, for this example, it is ultimately about running a task "in the background" while using readLine.

I do not post this because I cannot get it to work, but rather because I find there are a few sharp edges here. if you take your "traditional" CLI app and just "make it async" in a naive way - there be dragons.

so "doesn't work" means "does not execute concurrently" (not there is a bug or anything).

however, I feel that MainActor in CLI apps, "readLine" being blocking, "never-block-concurrency-runtime-tasks", the way that "traditional" CLI apps are made, and the mantra of "do not use detached!" are a spicy mix for developers.

I am super curious how the community thinks these types of apps should best be made.

edit: I edited the original post to better reflect what "works" means for me here...

also, about the "why is that second case working?":

Image you had some app working, a dev refactors the struct assignment and puts it in the task, suddenly everything grinds to a halt - I do not want the be the one debugging this. so I am just super curious what is going on here. I would have hoped that none of the cases would work (ie: run concurrently)... but now it feels like if you are lucky enough to randomly bypass whatever should be deadlocking you can get by without understand how close you are shaving it - one minor change and things are stuck.

Task.detached is the proper solution to getting out of a blocked actor context. For CLI apps, depending on your expectation, blocking main may be fine, and that's what many executables do today. But yeah, without proper async support for system APIs like readLine we have to manually work around things like this. Hopefully we have a full suite of proper solutions by Swift 6.

1 Like

Your second example changes nothing for me, so I don't know what you mean.

In general, as long as the developer understands the readLine is blocking, the rest of the behavior should be perfectly logical.

I haven’t tested this out but you might have luck with FileHandle.standardInput.bytes.lines, which is an AsyncSequence.

I agree that Swift Concurrency as it stands today leaves a lot to be desired in the IO realm; for example, the above API solves reading asynchronously but there’s no FileHandle API for async backpressure-enabled writing, and if you’re dealing with non-POSIX (or Win32) IO APIs you’re still on your own. Also very relevant to this discussion is another concurrent thread (pun intended) here on the Swift Forums:

2 Likes

Out of curiosity, I tried the first case; it emitted the following output.

q to exit
starting inner loop
Printing 1
Printing 2
Printing 3
Printing 4
Printing 5
Printing 6
q
inner loop goes brrr
Program ended with exit code: 0
Code
// [https://forums.swift.org/t/cli-app-readline-and-structured-concurrency/67330]

@main
enum ScratchPad {
    static func main () async {
        await run ()
    }
}

private func run () async {
    print("q to exit")

    let run = Task { await Foo().doStuff() }
    // let run = Task.detached { await Foo().doStuff() }

    while let line = readLine() {
        if line == "q" {
            run.cancel()
            break
        }
    }

    await run.value
}

private 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")
        }
    }
}

fascinating... I count three different behaviors then (@Jon_Shier reported both cases behaving the same, but NOT running concurrently...)

how are you running it?
I used swift run in a plain swift package with one executable target, swift 5.8.1.

I'll play around a bit more... the only thing that would remotely make sense to me is that release mode would "optimize" away the bit that run on MainActor an therefore directly short-cutting to the thread pool and debug would not - but that's just a stab in the dark...

edit:
ok, that might actually be what is happening, both examples "work" in release, but not in debug. adding some non-optimizable thing before doStuff (like a print) in the closure makes it get stuck in release as well. <crying cat with thumbs up>

1 Like

My build configuration in Xcode is set to Debug. I am running the executable from Xcode and also from the Terminal.

It's an AsyncSequence but unfortunately it's not async. It'll also block a thread with synchronous I/O (read). To make matters worse, it'll block exactly one thread. So if you, say, tried to read FileHandle.standardInput.bytes.lines as well as reading any other FileHandle.bytes, you would only get data from both streams if both are readable... (rdar://105881437).

6 Likes