Skip to content

Commit ded3bc9

Browse files
committed
Change win program resolution to match macOS/Linux
After [some careful study](https://github.com/lread/info-process-builder/blob/main/README.adoc#program-resolution), I now understand the underlying behaviour of ProcessBuilder on Windows. It has some nuances: - it resolves from the current dir before PATH (which CMD Shell does too - but PowerShell opted not to do for security reasons) - it only resolves `a` to `a.exe` - and has what seem to be bugs wrt to resolving when a `directory` working dir is specified These oddities were sometimes leaking through to babashka process when run from Windows. We now entirely take over the program resolution for Windows in our default `:program-resolver` which now matches the behaviour of PowerShell (with the exception of launching `.ps1` files, we do not do that), macOS and Linux. Note that the default `:program-resolver` is also employed for `exec` on all OSes. Because it matches macOS/Linux behaviour this does not present any problems. Tests now thoroughly exercise program resolution via explicitly constructed scenarios instead of awkwardly relying on what happens to be on the PATH. See new test-resources dir README for some details. Bb tasks adjusted accordingly to setup the `PATH` for tests. New `dev:bb` and `dev:jvm` tasks added to launch nREPLs with this same setup. README is updated with new section on Program Resolution. Closes #163 Closes #164
1 parent ae1656b commit ded3bc9

File tree

16 files changed

+618
-122
lines changed

16 files changed

+618
-122
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
[Babashka process](https://github.com/babashka/process)
44
Clojure library for shelling out / spawning sub-processes
55

6+
## Unreleased
7+
8+
- [#163](https://github.com/babashka/process/issues/163), [#164](https://github.com/babashka/process/issues/164): Program resolution strategy for `exec` and Windows now matches macOS/Linux/PowerShell ([@lread](https://github.com/lread))
9+
610
## 0.5.22 (2024-02-29)
711

812
- [#123](https://github.com/babashka/process/issues/123): `exec` now converts `:env` and `:extra-env` keywords ([@lread](https://github.com/lread))

README.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ ls: nothing: No such file or directory
9696
Execution error (ExceptionInfo) at babashka.process/check (process.cljc:113).
9797
```
9898

99-
To avoid throwing when the command's exit code is non-zero, use `:continue true`.
99+
To avoid throwing when the command's exit code is non-zero, use `:continue true`.
100100
You will still see the error printed to stderr, but no exception will be thrown. This is convenient
101101
when you want to handle the `:exit` code yourself:
102102

@@ -490,6 +490,31 @@ nil
490490
491491
Another solution is to let bash handle the pipes by shelling out with `bash -c`.
492492
493+
## Program Resolution
494+
495+
### macOS & Linux
496+
On macOS & Linux, programs are resolved the way you expect:
497+
498+
- `a` resolves against the system `PATH`
499+
- `./a` resolves against `:dir` if specified, otherwise the current working directory
500+
- `/some/absolute/a` resolves absolutely
501+
502+
In all cases, the working directory for `a` is `:dir`, if specified, otherwise your current working directory.
503+
504+
### Windows
505+
506+
Windows executable files have extensions, which, if not specified, are resolved in order: `.com`,`.exe`,`.bat`,`.cmd`.
507+
Programs are resolved in directories using the same rules as macOS, Linux, and Windows PowerShell.
508+
509+
> **Windows .ps1 TIP**: Babashka process will never resolve to, and cannot launch, `.ps1` scripts directly.
510+
To launch a `.ps1` script, you must do so through PowerShell.
511+
Example:
512+
> ```Clojure
513+
> (p/shell "powershell.exe -File .\\a.ps1")
514+
> ```
515+
516+
> **Windows TIP**: If you prefer a more CMD Shell-like experience where programs are resolved first in the current working directory, then on the `PATH`, and are OK with the security implications of doing so, you can override the default `:program-resolver` with your own.
517+
493518
## Differences with `clojure.java.shell/sh`
494519
495520
If `clojure.java.shell` works for your purposes, keep using it. But there are

bb.edn

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,58 @@
33
#_{:local/root "../../quickdoc"}
44
{:git/url "https://github.com/borkdude/quickdoc"
55
:git/sha "32e726cd6d785d00e49d4e614a05f7436d3831c0"}
6-
org.clj-commons/digest {:mvn/version "1.4.100"} }
6+
org.clj-commons/digest {:mvn/version "1.4.100"}}
7+
78
:tasks
8-
{:requires ([babashka.fs :as fs])
9+
{:requires ([babashka.cli :as cli]
10+
[babashka.fs :as fs])
11+
:init (do
12+
(def shell-opts {:extra-env
13+
{(if (fs/windows?) "Path" "PATH")
14+
(str (fs/canonicalize "target/test/on-path") fs/path-separator (System/getenv "PATH"))}})
15+
(defn parse-repl-args [args]
16+
(let [cli-spec {:spec
17+
{:host {:desc "Bind to host (use 0.0.0.0 to allow anyone to connect)"
18+
:alias :h
19+
:default "localhost"}}
20+
:restrict true}]
21+
(cli/parse-opts args cli-spec))))
922
clean {:doc "Delete build work"
1023
:task (do
1124
(fs/delete-tree "target")
1225
(shell {:dir "test-native"} "bb clean"))}
26+
27+
dev:jvm {:doc "Start a jvm nREPL server with PATH appropriatly setup for tests"
28+
:task (let [opts (parse-repl-args *command-line-args*)
29+
host (:host opts)]
30+
(shell shell-opts
31+
"clj" "-M:clj-1.11:test:nrepl/jvm" "-h" host "-b" host))}
32+
33+
-bb {:doc "Internal - launched by dev:bb, test:bb"
34+
:extra-paths ["test"]
35+
:extra-deps {;; inherit base deps from deps.edn
36+
babashka/process {:local/root "."}
37+
;; repeat necessary :test deps from deps.edn
38+
io.github.cognitect-labs/test-runner {:git/tag "v0.5.1" :git/sha "dfb30dd"}}
39+
:requires ([cognitect.test-runner])
40+
:task (let [target (cli/coerce (first *command-line-args*) :keyword) ;; should be either :test or :dev
41+
args (rest *command-line-args*)]
42+
;; flag: sub-process should reload babashka.process
43+
(System/setProperty "babashka.process.test.reload" "true")
44+
;; flag: force use of bb even if natively compiled version of run-exec exists
45+
(System/setProperty "babashka.process.test.run-exec" "bb")
46+
;; run from babashka.process sources, not built-in babaska.process
47+
(require '[babashka.process] :reload)
48+
(case target
49+
:test (apply cognitect.test-runner.-main args)
50+
:dev (let [opts (parse-repl-args args)]
51+
(babashka.nrepl.server/start-server! opts)
52+
(deref (promise)))))}
53+
54+
dev:bb {:doc "Start a bb nREPL server with PATH appropriately setup for tests"
55+
:task (apply shell shell-opts
56+
"bb -bb :dev" *command-line-args*)}
57+
1358
quickdoc {:doc "Invoke quickdoc"
1459
:requires ([quickdoc.api :as api])
1560
:task (api/quickdoc {:git/branch "master"
@@ -22,23 +67,12 @@
2267

2368
test:native {:doc "Run exec tests with native runner (requires GraalVM compilation)."
2469
:depends [-prep-native-exec]
25-
:task (apply clojure "-M:test:clj-1.11" "--namespace" "babashka.process-exec-test" *command-line-args*)}
70+
:task (apply clojure shell-opts
71+
"-M:test:clj-1.11" "--namespace" "babashka.process-exec-test" *command-line-args*)}
2672

2773
test:bb {:doc "Run all tests under bb"
28-
:extra-paths ["test"]
29-
:extra-deps {;; inherit base deps from deps.edn
30-
babashka/process {:local/root "."}
31-
;; repeat necessary :test deps from deps.edn
32-
io.github.cognitect-labs/test-runner {:git/tag "v0.5.1" :git/sha "dfb30dd"}}
33-
:requires ([cognitect.test-runner])
34-
:task (do
35-
;; flag: sub-process should reload babashka.process
36-
(System/setProperty "babashka.process.test.reload" "true")
37-
;; flag: force use of bb even if natively compiled version of run-exec exists
38-
(System/setProperty "babashka.process.test.run-exec" "bb")
39-
;; run from babashka.process sources, not built-in babaska.process
40-
(require '[babashka.process] :reload)
41-
(apply cognitect.test-runner.-main *command-line-args*))}
74+
:task (apply shell shell-opts
75+
"bb -bb :test" *command-line-args*)}
4276

4377
test:jvm {:doc "Run jvm tests, optionally specify clj-version (ex. :clj-1.10 :clj-1.11(default) or :clj-all)"
4478
:requires ([clojure.string :as str]
@@ -72,7 +106,9 @@
72106
(doseq [alias aliases]
73107
(do
74108
(println (format "-[Running jvm tests for %s]-" alias))
75-
(apply clojure (str "-M:test" alias) "--namespace" "babashka.process-test" args))))}
109+
(apply clojure
110+
shell-opts
111+
(str "-M:test" alias) "--namespace" "babashka.process-test" args))))}
76112

77113
;; hidden CI support tasks
78114
-ci-install-jdk {:doc "Helper to download and install jdk under ~/tools on circleci"

deps.edn

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,10 @@
55
:clj-1.9 {:extra-deps {org.clojure/clojure {:mvn/version "1.9.0"}}}
66
:clj-1.10 {:extra-deps {org.clojure/clojure {:mvn/version "1.10.3"}}}
77
:clj-1.11 {:extra-deps {org.clojure/clojure {:mvn/version "1.11.3"}}}
8-
:clj-1.12 {:extra-deps {org.clojure/clojure {:mvn/version "1.12.0-beta1"}}}}}
8+
:clj-1.12 {:extra-deps {org.clojure/clojure {:mvn/version "1.12.0-beta1"}}}
9+
:nrepl/jvm {:extra-deps {nrepl/nrepl {:mvn/version "1.2.0"}
10+
cider/cider-nrepl {:mvn/version "0.49.1"}}
11+
:jvm-opts ["-XX:-OmitStackTraceInFastThrow"]
12+
:main-opts ["-m" "nrepl.cmdline"
13+
"--middleware" "[cider.nrepl/cider-middleware]"
14+
"-i"]}}}

src/babashka/process.cljc

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -178,17 +178,28 @@
178178
(str/includes? "windows")))
179179

180180
(defn- -program-resolver [{:keys [program dir]}]
181-
;; this should make life easier and not cause any bugs that weren't there previously
182-
;; on exception we just return the program as is
183-
(try
184-
(if (fs/relative? program)
185-
(if-let [f (fs/which (if dir
186-
(-> (fs/file dir program) fs/absolutize)
187-
program))]
188-
(str f)
189-
program)
190-
program)
191-
(catch Throwable _ program)))
181+
;; Used by default:
182+
;; - for all program resolution when running on Windows
183+
;; - by `exec` on all OSes
184+
;; Default ProcessBuilder behaviour on macOS/Linux does this work naturally
185+
;; and is therefore unneeded.
186+
;;
187+
;; ProcessBuilder program resolution on Windows does not entirely match
188+
;; behaviour of CMD Shell nor PowerShell.
189+
;; Here we adapt resolution to match PowerShell (with the exclusion of resolving
190+
;; .ps1 scripts) which follows the same strategy as macOS/Linux.
191+
(if-let [resolved (cond
192+
(fs/absolute? program)
193+
(fs/which program) ;; to resolve any extensions
194+
195+
(fs/parent program)
196+
(fs/which (fs/file (or dir (fs/cwd))
197+
program))
198+
199+
:else
200+
(fs/which program))]
201+
(-> resolved fs/absolutize fs/normalize str)
202+
(throw (ex-info (str "Cannot resolve program: " program) {}))))
192203

193204
(defn ^:no-doc default-program-resolver
194205
[{:keys [program] :as opts}]

test-native/src/babashka/test_native/run_exec.clj

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,10 @@
4545
"call with a list of `args` for `babashka.process/exec`
4646
4747
for adhoc (under bash, Windows shell will have different escaping rules):
48-
bb exec-run.clj \"({:arg0 'new-arg0'} bb wd.clj :out foo :exit 3)\"
48+
bb run-exec.clj \"({:arg0 'new-arg0'} bb wd.clj :out foo :exit 3)\"
4949
5050
to avoid shell command escaping hell can also read args from edn file
51-
bb exec-run.clj --file some/path/here.edn"
51+
bb run-exec.clj --file some/path/here.edn"
5252
[& args]
5353
(let [exec-args (parse-args args)]
5454
(try
@@ -59,7 +59,7 @@
5959
(System/exit 42)
6060
(catch Exception ex
6161
;; an error occurred launching exec
62-
(println (pr-str (Throwable->map ex)))))))
62+
(println (pr-str {:bbp-test-run-exec-exception (Throwable->map ex)}))))))
6363

6464
;; support invocation from babashka when run as script
6565
(when (= *file* (System/getProperty "babashka.file"))

test-resources/README.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Babashka Process Test Resources
2+
3+
For test coverage, we need executables that emit their path and their current working directory.
4+
For example, on Windows:
5+
```
6+
> .\print-dirs.bat
7+
exepath: Z:\babashka\process\test-resources\print-dirs.bat
8+
workdir: Z:\babashka\process\test-resources
9+
> cd ..
10+
>test-resources\print-dirs
11+
exepath: Z:\babashka\process\test-resources\print-dirs.exe
12+
workdir: Z:\babashka\process
13+
```
14+
15+
This is straightforward for Linux/macOS `.sh`, Windows `.cmd` and Windows `.bat` variants, but for the Windows `.exe` variant, we need to create a binary.
16+
17+
## Generating a Windows .exe
18+
19+
It doesn't matter how `print-dirs.exe` is created; it won't need to be recreated often (we commit it to source control).
20+
21+
Since it generates relatively small binaries very quickly for any architecture, we've built a `print-dirs.exe` using Go.
22+
Source is in [print-dirs.go](print-dirs.go)
23+
24+
### Minimal Build
25+
Presuming you have [Go](https://go.dev/) installed, from this dir:
26+
27+
```shell
28+
GOOS=windows GOARCH=amd64 go build -o print-dirs.exe print-dirs.go
29+
```
30+
31+
For me, this generated a 1.9mb `print-dirs.exe`.
32+
33+
### Build a Smaller Exe
34+
Since this `.exe` is checked into version control, I opted to create a smaller binary by adding the following `-ldflags` to strip debug info:
35+
36+
```shell
37+
GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o print-dirs.exe print-dirs.go
38+
```
39+
40+
For me, the generated `print-dirs.exe` is now 1.3mb.
41+
To further shrink down the binary, you can use [UPX](https://upx.github.io/) (which is also cross-platform):
42+
43+
```
44+
upx --ultra-brute print-dirs.exe
45+
```
46+
47+
And now `print-dirs.exe` is 457kb.
48+
49+
### Building With Docker
50+
If you don't want to install Go and UPX on your dev box but have docker installed, you can build from a docker image like so:
51+
52+
``` shell
53+
docker run --rm \
54+
-v "$PWD":/src \
55+
-w /src \
56+
devopsworks/golang-upx:latest \
57+
bash -c 'GOOS=windows GOARCH=amd64 \
58+
go build -ldflags "-s -w" -o print-dirs.exe print-dirs.go &&
59+
upx --ultra-brute print-dirs.exe'
60+
```
61+
62+
This is ultimately the command I ran to create our Windows .exe.
63+
64+
## What about Windows .com?
65+
66+
These days, `.com` executables are rare.
67+
68+
I could not find an easy way to create one.
69+
Our tests use `print-dirs.exe` copied to `print-dirs.com` to support `.com` test cases.

test-resources/print-dirs.bat

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
@echo off
2+
echo exepath: %~dpnx0
3+
echo workdir: %cd%

test-resources/print-dirs.cmd

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
@echo off
2+
echo exepath: %~dpnx0
3+
echo workdir: %cd%

test-resources/print-dirs.exe

457 KB
Binary file not shown.

0 commit comments

Comments
 (0)