babashka

2026-03-12T09:09:02.436149Z

I am running into something I think may be a bug in babashka, or possibly user error. I'm finding that when I have a future blocking on a lazy-seq, the program doesn't exit. I tried calling it with --force-exit but that doesn't seem to make a difference, unless I'm just calling it wrong. I'll include a small reproduction case in the thread.

2026-03-12T09:09:43.803989Z

#!/usr/bin/env bb

(require '[babashka.process :as proc]
         '[clojure.java.io :as io])

(def path "./repro")

(let [p (proc/process (str "tail -f -n +1 " path))]
  (with-open [reader (io/reader (:out p))]
    ;; this future runs forever:
    (future
      (doseq [l (line-seq reader)]
        :do-nothing))

    (println "sleeping to let the future catch up")
    (Thread/sleep 1000)
    (println "done sleepin")
    ;; At this point, if we don't manually kill the process, we'll block forever:
    #_(.destroy (:proc p))))

2026-03-12T09:13:38.167509Z

This script is named "repro", and I'm just having it read from the script file itself since I just needed the child process to tail -f any old file forever.

borkdude 2026-03-12T10:34:01.633499Z

This is what I'm seeing:

borkdude@MBP25-3 /tmp $ bb /tmp/foo.bb
sleeping to let the future catch up
done sleepin
borkdude@MBP25-3 /tmp $

2026-03-12T10:43:26.662589Z

Did you update the path variable to be ./foo.bb ?

2026-03-12T10:43:46.240979Z

Because if it can't open the file specified by path, it will behave as you describe.

borkdude 2026-03-12T10:43:52.756109Z

no, let me try

2026-03-12T10:44:39.328269Z

The path could just as well be "/etc/hosts" or something but that looked scarier - I just need to tail from SOME valid file.

borkdude 2026-03-12T10:45:11.801209Z

ah yes, I can repro it now

2026-03-12T10:46:00.727079Z

Cool!

borkdude 2026-03-12T10:48:55.055319Z

Can you make a github issue?

borkdude 2026-03-12T10:49:11.979629Z

perhaps it's a feature, not a bug, but I'd have to check

2026-03-12T11:08:18.564299Z

Yes, I will try.

2026-03-12T13:04:55.527529Z

i think that is expected, java/graalvm will not end the program if there is a non daemon thread still running, the future runs forever because you're doing a "tail -f"

2026-03-12T13:06:08.699549Z

It even occurs with the β€”force-exit flag

2026-03-12T13:35:22.616469Z

The behavior also is different depending on whether the future gets to the end of the input and blocks on more lines or not.

2026-03-12T13:35:47.750329Z

That inconsistency alone suggests to me that there is a problem to be addressed.

borkdude 2026-03-12T13:36:24.986089Z

doesn't that make sense since the future will end if there's no more input?

2026-03-12T13:53:24.475029Z

it seems there is some deadlock/racing condition with the fact that the reader is closed in a different thread than is being read, if you move the with-open to inside the future, the program ends

borkdude 2026-03-12T13:53:43.048759Z

πŸ‘

2026-03-12T14:42:44.323189Z

To answer your question asked at 2:36pm, no, it doesn't make sense, because the future is (intentionally) written in such a way that it will never end - it will block forever waiting for another line. What I was pointing out is that if you happen to reach the end of the program before the future has reached the end of the available input, the program will exit. The difference in this case is not whether the future is running or not - it's always running in both examples I'm talking about. The behavior with not exiting appears to happen when the future is blocking waiting for a line when all lines have been exhausted.

2026-03-12T14:43:02.280329Z

I'll try to file a github issue later and I'll include this info and you can decide if you think it qualifies as a bug or not.

borkdude 2026-03-12T14:56:26.931839Z

@rfhayashi is right. consider this:

(let [p (proc/process (str "tail -f -n +1 " path))]
  (with-open [reader (io/reader (:out p))]
    ;; this future runs forever:
    (future
      (doseq [l (line-seq reader)]
        :do-nothing))

    (println "sleeping to let the future catch up")
    (Thread/sleep 1000)
    (println "done sleepin"))
  (prn :dude))
It never prints :dude. Why not? The Thread is stuck on a blocking read. It's not interruptible.

2026-03-12T14:57:43.218349Z

Interesting - so is your conclusion that there's nothing that can be done?

borkdude 2026-03-12T14:58:34.789969Z

not on bb's side. clojure JVM has exactly the same behavior, so I don't consider it a bb bug

2026-03-12T14:59:17.646279Z

Ok - I won't file the issue then! It's still interesting - and it's not hard to work around it.

2026-03-12T14:59:37.974159Z

Thanks for looking into it, and thanks for providing insights @rfhayashi

2026-03-12T15:00:21.665489Z

This came up organically while writing a program to parse logs and visualize them with charm.

2026-03-12T15:00:49.487589Z

I needed to force the sequence using future so that charm's update function wouldn't get stuck (forever) when there wasn't any more input.

2026-03-12T15:01:11.613089Z

I wonder if there's a way to do that, without using future ... I tried also using core/async threads, with no better luck.

2026-03-12T15:06:34.251669Z

I suppose by reimplementing line-seq to not use .readLine on BufferedReader, but rather something that only read when the reader was ready, and otherwise just periodically slept. Inefficient, but I'm reasonably sure it would work and be interruptible.

2026-03-12T15:13:18.877719Z

I think I'd rather just live with the workarounds - either cause the child process to exit, or just call System/exit.

borkdude 2026-03-12T15:22:07.200909Z

Using Claude I was able to find out more. Both BufferedRead.readLine and BufferedReader.close synchronize on the same lock. So it's kind of a race condition. If the thread aqcuired the lock and is waiting for input, then close is waiting for the lock and will never execute. If close goes first, then the thread will just die quickly without reading input. So it's kind of indeterministic too. β€’ https://github.com/openjdk/jdk/blob/8444fdae4afd99369a321bda95d1ee034f1835f6/src/java.base/share/classes/java/io/Reader.java#L271 β€’ https://github.com/openjdk/jdk/blob/8444fdae4afd99369a321bda95d1ee034f1835f6/src/java.base/share/classes/java/io/BufferedReader.java#L321 β€’ https://github.com/openjdk/jdk/blob/8444fdae4afd99369a321bda95d1ee034f1835f6/src/java.base/share/classes/java/io/BufferedReader.java#L525

borkdude 2026-03-12T15:34:52.198069Z

This is an even simpler repro.

(require '[babashka.process :refer [process]])
(let [reader ( (:out (process "sleep 10")))]
  (future (.readLine reader))
  (.close reader))
Sometimes the script will take 10s to run, other times 0s, depending on who is acquiring the lock first.

seancorfield 2026-03-12T20:15:39.234139Z

Q about contributing to Babashka. I just forked it and cloned my fork. doc/dev.md says:

## Test

Test on the JVM (for development):

    script/test
But when I run that I get
> script/test
openjdk version "25.0.1" 2025-10-21
OpenJDK Runtime Environment Homebrew (build 25.0.1)
OpenJDK 64-Bit Server VM Homebrew (build 25.0.1, mixed mode, sharing)
running tests part 1
Execution error (FileNotFoundException) at babashka.test-utils/eval349$loading (test_utils.clj:1).
Could not locate babashka/fs__init.class, babashka/fs.clj or babashka/fs.cljc on classpath.

Full report at:
/tmp/clojure-7358560808974394143.edn
Subprocess failed (exit code: 1)

borkdude 2026-03-12T20:18:24.608189Z

You forgot to clone the submodules?

seancorfield 2026-03-12T20:20:10.569449Z

That's a long way down the instructions... ...if I've already cloned it, is there a way to "clone the submodules", or do I have to start over? (I've never used git submodules)

borkdude 2026-03-12T20:20:30.305279Z

yes, the link describes how :)

borkdude 2026-03-12T20:24:10.217289Z

here it's also written: https://github.com/babashka/babashka/blob/master/doc/build.md#clone-repository there seems to be a tiny difference, --init. not sure what does does

borkdude 2026-03-12T20:24:18.009479Z

perhaps you need that if you haven't cloned them

seancorfield 2026-03-12T20:34:03.269079Z

Neither of those commands work, so I guess it's delete and start again.

seancorfield 2026-03-12T20:35:52.850069Z

Tests are running now. You might want to mention the submodule stuff *above* the Test section πŸ™‚

seancorfield 2026-03-12T20:38:39.236869Z

Is this error expected while running tests? It doesn't seem to fail any tests:

panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x10 pc=0x4f7946]

goroutine 1 [running]:
, {0x6d2a60, 0xc000012060})
        /home/circleci/project/babashka/ops.go:72 +0x86
main.main()
        /home/circleci/project/main.go:207 +0x25

seancorfield 2026-03-12T20:39:27.461479Z

(that was just before running flaky tests BTW)

borkdude 2026-03-12T20:40:47.056009Z

yeah you can ignore that one, it's a pod that's killed

πŸ‘πŸ» 1
seancorfield 2026-03-12T20:52:45.148359Z

PR submitted.

borkdude 2026-03-12T22:05:24.087449Z

Thanks and congrats on your first bb PR!

seancorfield 2026-03-12T22:49:15.331609Z

Thank you for being so receptive to my little foible 🀣

😁 1