home: hub: zuo

Download patch

ref: f9f86521cbe7a79fc2fb6c70cb4e741d1ce93391
parent: 12bbec6bce0c96374d58f0f79e50d71ed64db94b
author: Matthew Flatt <mflatt@racket-lang.org>
date: Tue Oct 18 01:50:06 CDT 2022

support interaction with a GNU Make jobserver

Also adds `fd-poll`, refines waiting on processes at termination, adds
a `-c` command-line flag, and adds a 'minor-version key in
`(runtime-env)`.

This is version 1.1.

--- a/Makefile.in
+++ b/Makefile.in
@@ -34,7 +34,8 @@
 
 .PHONY: check
 check: zuo
-	./zuo . check
+	./zuo . to-run/zuo
+	to-run/zuo . check
 
 .PHONY: install
 install: zuo
--- a/README.md
+++ b/README.md
@@ -41,7 +41,8 @@
 configuration.
 
 The Zuo executable runs only modules. If you run Zuo with no
-command-line arguments, then it loads `main.zuo`. Otherwise, the first
+command-line arguments, then it loads `main.zuo`. Use the `-c`
+flag to provide module text as an argument. Otherwise, the first
 argument to Zuo is a file to run or a directory containing a
 `main.zuo` to run, and additional arguments are delivered to that Zuo
 program via the `runtime-env` procedure. Running the command
--- a/build.zuo
+++ b/build.zuo
@@ -49,7 +49,7 @@
               (rule (list image_zuo.c
                           (input-data-target 'config (cons
                                                       lib-path
-                                                      (map (lambda (key) (hash-ref config key))
+                                                      (map (lambda (key) (hash-ref config key #f))
                                                            '(CC CPPFLAGS CFLAGS LDFLAGS LIBS))))
                           (quote-module-path))
                     (lambda ()
--- a/lib/zuo/build.zuo
+++ b/lib/zuo/build.zuo
@@ -2,6 +2,7 @@
 (require "cmdline.zuo"
          "thread.zuo"
          "config.zuo"
+         "jobserver.zuo"
          "private/build-db.zuo")
 
 (provide (rename-out [make-target target]
@@ -48,7 +49,7 @@
 (struct token (target      ; the target that received this token
                ch          ; a channel to access the build state
                seen        ; to detect dependency cycles
-               resource-ch ; build state's resource channel
+               resourcer   ; build state's resource manager
                uni?))      ; build state's `uni?` flag
 
 (struct target (key       ; shortcut: `string->symbol` of the path
@@ -212,7 +213,7 @@
                      db            ; key -> (cons sha256 dep-sha256s) | #t [for db file itself]
                      time-cache    ; key -> (cons timestamp sha256)
                      saw-targets   ; key -> target [to detect multiple for same output]
-                     resource-ch   ; channel with available resources enqueued
+                     resourcer     ; build stat's resource manager
                      log?          ; logging enabled?
                      uni?))        ; just one job?
 
@@ -222,15 +223,23 @@
     (unless (target? t) (arg-error 'build "target or list of targets" t-in))
     (unless (hash? options) (arg-error 'build "hash table" options))
     (unless (or (not token) (token? token)) (arg-error 'build "build token or #f" token))
-    (let* ([resource-ch (and token (token-resource-ch token))]
-           [num-jobs (if token
-                         ;; we only need whether it's 1 job or more:
-                         (if (token-uni? token) 1 2)
-                         (hash-ref options 'jobs (default-jobs)))]
+    (let* ([resourcer (and token (token-resourcer token))]
+           [given-num-jobs (if token
+                               ;; we only need whether it's 1 job or more:
+                               (if (token-uni? token) 1 2)
+                               (hash-ref options 'jobs #f))]
            [seen (hash)])
       ;; Start a threading context, so we can have parallel build tasks
       ((if token (lambda (th) (th)) call-in-main-thread)
        (lambda ()
+         (define jobserver-resourcer (and (not resourcer)
+                                          (not given-num-jobs)
+                                          (maybe-jobserver-client)))
+         (define num-jobs (or given-num-jobs
+                              (and jobserver-resourcer
+                                   ;; assume jobserver allows more than 1:
+                                   2)
+                              (default-jobs)))
          (define ch (channel))
          (define state (build-state ch
                                     (hash)
@@ -238,15 +247,16 @@
                                     (hash)
                                     (hash)
                                     (hash)
-                                    (or resource-ch
-                                        (make-resources num-jobs))
+                                    (or resourcer
+                                        jobserver-resourcer
+                                        (make-resourcer num-jobs))
                                     (or (hash-ref options 'log? #f)
                                         (assoc "ZUO_BUILD_LOG" (hash-ref (runtime-env) 'env)))
                                     (= num-jobs 1)))
-         (when resource-ch
+         (when resourcer
            (release-resource state "nested"))
          (do-build t state seen #t)
-         (when resource-ch
+         (when resourcer
            (acquire-resource state "continue from nested")))))))
 
 (define (build/maybe-dep t-in token add-dep?)
@@ -354,7 +364,7 @@
   (define tok (token t
                      (build-state-ch state)
                      new-seen
-                     (build-state-resource-ch state)
+                     (build-state-resourcer state)
                      (build-state-uni? state)))
   (put-state (token-ch tok) queued-state "rule")
 
@@ -787,20 +797,27 @@
 (define (put-state ch state who)
   (channel-put ch state))
 
-(define (make-resources n)
+(define (make-resourcer n)
   (define ch (channel))
   (let loop ([n n])
     (unless (= n 0)
       (channel-put ch 'go)
       (loop (- n 1))))
-  ch)
+  (lambda (msg)
+    (cond
+      [(eq? msg 'get)
+       (channel-get ch)
+       (void)]
+      [(eq? msg 'put)
+       (channel-put ch 'go)]
+      [else
+       (error "bad resourcer msg" msg)])))
 
 (define (acquire-resource state who)
-  (channel-get (build-state-resource-ch state))
-  (void))
+  ((build-state-resourcer state) 'get))
 
 (define (release-resource state who)
-  (channel-put (build-state-resource-ch state) 'go))
+  ((build-state-resourcer state) 'put))
 
 (define (log-changed same? who state)
   (unless same?
--- /dev/null
+++ b/lib/zuo/jobserver.zuo
@@ -1,0 +1,126 @@
+#lang zuo/base
+(require zuo/thread
+         zuo/glob)
+
+(provide maybe-jobserver-jobs
+         maybe-jobserver-client)
+
+(define (maybe-jobserver-jobs)
+  (try-jobserver-client poll-jobserver-client))
+
+(define (maybe-jobserver-client)
+  (try-jobserver-client create-jobserver-client))
+
+(define (try-jobserver-client create-jobserver-client)
+  (define a (and (eq? 'unix (system-type))
+                 (assoc "MAKEFLAGS" (hash-ref (runtime-env) 'env))))
+  (and a
+       (let ([args (shell->strings (cdr a))])
+         (and (ormap (lambda (arg) (string=? "-j" arg)) args)
+              (ormap (let ([match? (let ([fds? (glob->matcher "--jobserver-fds=*")]
+                                         [auth? (glob->matcher "--jobserver-auth=*")])
+                                     (lambda (s) (or (fds? s) (auth? s))))])
+                       (lambda (arg)
+                         (and (match? arg)
+                              (let ([fds (map string->integer
+                                              (string-split (cadr (string-split arg "=")) ","))])
+                                (and (= (length fds) 2)
+                                     (andmap integer? fds)
+                                     ;; read in all available job tokens, then give them back up,
+                                     ;; just so we can infer the number of tokens that would be available
+                                     (let ([in-no (car fds)]
+                                           [out-no (cadr fds)])
+                                       (let ([in (fd-open-input in-no)]
+                                             [out (fd-open-output out-no)])
+                                         (and (fd-valid? in)
+                                              (fd-valid? out)
+                                              (create-jobserver-client in out in-no out-no)))))))))
+                     args)))))
+
+(define (poll-jobserver-client in out in-no/ignored out-no/ignored)
+  (let ([s (fd-read in 'avail)])
+    (and (string? s)
+         (begin
+           (fd-write out s)
+           (+ 1 (string-length s))))))
+
+(define (create-jobserver-client in/ignored out/ignored in-no out-no)
+  (define msg-ch (channel))
+  (define manager-in+out (launch-manager in-no out-no))
+  (thread (lambda ()
+            (let loop ([reader? #f]
+                       [held 0]
+                       [queue '()])
+              (cond
+                [(and (pair? queue)
+                      (not reader?))
+                 (launch-reader manager-in+out msg-ch)
+                 (loop #t held queue)]
+                [else
+                 (define msg+ (channel-get msg-ch))
+                 (define msg (car msg+))
+                 (cond
+                   [(eq? msg 'get)
+                    (define reply-ch (cdr msg+))
+                    (loop reader? held (append queue (list reply-ch)))]
+                   [(eq? msg 'avail)
+                    (cond
+                      [(pair? queue)
+                       (channel-put (car queue) 'go)
+                       (loop #f (+ 1 held) (cdr queue))]
+                      [else
+                       ;; no one is waiting anymore, so release back to the jobserver
+                       (fd-write (cdr manager-in+out) "-")
+                       (loop #f held queue)])]
+                   [(eq? msg 'put)
+                    (define reply-ch (cdr msg+))
+                    (cond
+                      [(pair? queue)
+                       (channel-put (car queue) 'go)
+                       (channel-put reply-ch 'done)
+                       (loop reader? held (cdr queue))]
+                      [else
+                       (fd-write (cdr manager-in+out) "-")
+                       (channel-put reply-ch 'done)
+                       (loop reader? (- held 1) queue)])]
+                   [else
+                    (error "unrecognized jobserver-manager message" msg)])]))))
+  (lambda (msg)
+    (unless (or (eq? msg 'get) (eq? msg 'put))
+       (error "jobserver-client: bad message" msg))
+    (define reply-ch (channel))
+    (channel-put msg-ch (cons msg reply-ch))
+    (channel-get reply-ch)
+    (void)))
+
+(define jobserver-manager
+  (hash-ref (module->hash 'zuo/private/jobserver-manager) 'datums))
+
+(define (launch-manager in-no out-no)
+  ;; The job of the manager process is just to survive at a point where
+  ;; the enclosing process is trying to exit, possibly due to an error.
+  ;; It will notice that the stdin pipe is closed and clean up.
+  (define p (process (hash-ref (runtime-env) 'exe)
+                     "-c"
+                     (~a (car jobserver-manager) "\n"
+                         (string-join (map ~s (cdr jobserver-manager))))
+                     (~a in-no)
+                     (~a out-no)
+                     (hash 'stdin 'pipe
+                           'stdout 'pipe)))
+  (cons (hash-ref p 'stdout)
+        (hash-ref p 'stdin)))
+
+(define (launch-reader in+out msg-ch)
+  ;; The job of a reader process is to read one byte of input and
+  ;; then exit, because we can wait on process exiting.
+  (define p (process (hash-ref (runtime-env) 'exe)
+                     "-c"
+                     (~a "#lang zuo/kernel\n"
+                         "(exit (if (eq? eof (fd-read (fd-open-input 'stdin) 1)) 1 0))")
+                     (hash 'stdin (car in+out)
+                           'cleanable? #f)))
+  (fd-write (cdr in+out) "+")
+  (thread (lambda ()
+            (thread-process-wait (hash-ref p 'process))
+            (channel-put msg-ch '(avail)))))
--- /dev/null
+++ b/lib/zuo/private/jobserver-manager.zuo
@@ -1,0 +1,52 @@
+#lang zuo/datum
+
+;; Used by "../jobserver.zuo"
+
+"#lang zuo/base"
+(require zuo/thread)
+
+(define args (hash-ref (runtime-env) 'args))
+
+(define stdin (fd-open-input 'stdin))
+(define stdout (fd-open-output 'stdout))
+
+(define in (fd-open-input (string->integer (list-ref args 0))))
+(define out (fd-open-output (string->integer (list-ref args 1))))
+
+;; jobserver-manager's job is to reliably clean up on exit,
+;; so disable signals
+(suspend-signal)
+
+(let loop ([held 0] [waiting 0])
+  (define ready (fd-poll (if (= 0 waiting)
+                             (list stdin)
+                             (list stdin in))))
+  (cond
+    [(eq? ready stdin)
+     (define b (fd-read stdin 1))
+     (cond
+       [(eq? b eof)
+        ;; parent has exited, so release all resources
+        (let done-loop ([held held])
+          (when (> held 1)
+            (fd-write out "x")
+            (done-loop (- held 1))))]
+       [(equal? b "+")
+        ;; acquire request
+        (cond
+          [(> held 0)
+           (loop held (+ waiting 1))]
+          [else
+           ;; acquired initial
+           (fd-write stdout "x")
+           (loop (+ held 1) waiting)])]
+       [else
+        ;; release
+        (when (> held 1)
+          (fd-write out "x"))
+        (loop (- held 1) waiting)])]
+    [else
+     (fd-read in 1)
+     ;; acquired
+     (fd-write stdout "x")
+     (loop (+ held 1) (- waiting 1))]))
--- a/lib/zuo/private/main.zuo
+++ b/lib/zuo/private/main.zuo
@@ -6,7 +6,8 @@
          zuo/build
          zuo/shell
          zuo/c
-         zuo/glob)
+         zuo/glob
+         zuo/jobserver)
 
 (provide (all-from-out zuo/private/base/main
                        zuo/cmdline
@@ -15,4 +16,5 @@
                        zuo/build
                        zuo/shell
                        zuo/c
-                       zuo/glob))
+                       zuo/glob
+                       zuo/jobserver))
--- a/tests/file-handle.zuo
+++ b/tests/file-handle.zuo
@@ -130,3 +130,40 @@
     (check (equal? (fd-read in len) (substring big 0 len)))
     (check (fd-read in 1) (string (string-ref big len)))
     (fd-close in)))
+
+(check (fd-poll '() 0) #f)
+(check (fd-poll '() 1) #f)
+(check-arg-fail (fd-poll 'stdin) "not a list of")
+(check-arg-fail (fd-poll '(stdin)) "not a list of")
+(check-arg-fail (fd-poll '() 'oops) "not a nonnegative integer or #f")
+
+(define zuo.exe (hash-ref (runtime-env) 'exe))
+
+(let ([ht (process zuo.exe
+                   "-c"
+                   (~a "#lang zuo/base\n"
+                       (~s '(let ()
+                              (define in (fd-open-input 'stdin))
+                              (define out (fd-open-output 'stdout))
+                              (let loop ()
+                                (define msg (fd-read in 1))
+                                (unless (eq? msg eof)
+                                  (fd-write out msg)
+                                  (loop))))))
+                   (hash 'stdin 'pipe
+                         'stdout 'pipe))])
+  (define in (hash-ref ht 'stdout))
+  (define out (hash-ref ht 'stdin))
+  (check (fd-poll (list in) 0) #f)
+  (check (fd-poll (list in out) 0) out)
+  (check (fd-poll (list in out)) out)
+  (fd-write out "x")
+  (check (fd-poll (list in)) in)
+  (check (fd-poll (list in) 0) in)
+  (check (fd-poll (list in in in) 0) in)
+  (check (fd-poll (list in out)) in)
+  (check (fd-read in 1) "x")
+  (fd-close in)
+  (fd-close out)
+  (process-wait (hash-ref ht 'process))
+  (void))
--- a/zuo-doc/fake-zuo.rkt
+++ b/zuo-doc/fake-zuo.rkt
@@ -145,6 +145,7 @@
     fd-close
     fd-read
     fd-write
+    fd-poll
     eof
     fd-terminal?
     file->string
@@ -234,6 +235,9 @@
     call-in-main-thread
     thread? thread channel? channel channel-put channel-get
     thread-process-wait
-    config-file->hash))
+    config-file->hash
+
+    maybe-jobserver-client
+    maybe-jobserver-jobs))
 
 (intro-define-fake)
--- a/zuo-doc/lang-zuo.scrbl
+++ b/zuo-doc/lang-zuo.scrbl
@@ -765,7 +765,7 @@
 
 Loads @racket[mod-path] if it has not been loaded already, and returns
 the @tech{hash table} representation of the loaded module. See also
-Secref["module-protocol"]}
+@secref["module-protocol"]}
 
 @defproc[(dynamic-require [mod-path module-path?] [export symbol?]) any/c]{
 
@@ -1000,6 +1000,19 @@
 associated with @racket[handle], erroring for any other kind of
 @racket[handle].}
 
+@defproc[(fd-poll [handles (listof handle?)] [timeout-msecs (or/c integer? #f) #f]) (or/c handle? #f)]{
+
+Given a list of open input and output file descriptor handles as
+@racket[handles], checks whether any is ready for reading or writing.
+If @racket[timeout-msecs] is @racket[#f], @racket[fd-poll] blocks
+until at least one is ready, and then it returns the first element of
+@racket[handles] that is ready. If @racket[timeout-msecs] is a number,
+then it specifies a number of milliseconds to wait; the result is
+@racket[#f] if no handle in @racket[handles] is ready before
+@racket[timeout-msecs] milliseconds pass.
+
+@history[#:added "1.1"]}
+
 @defproc[(fd-terminal? [handle handle?] [check-ansi? any/c #f]) boolean?]{
 
 Returns @racket[#t] if the open input or output stream associated with
@@ -1093,7 +1106,9 @@
       created process to terminate; otherwise, and by default, the Zuo
       process waits for every processes created with @racket[process]
       to terminate before exiting itself, whether exiting normally, by
-      an error, or by a received termination signal (such as Ctl-C).}
+      an error, or by a received termination signal (such as Ctl-C);
+      any still-open input or output pipe created for the process is
+      closed before waiting for processes to exit.}
 
 @item{@racket['exact?] mapped to boolean (or any value): if not
       @racket[#f], a single @racket[arg] must be provided, and it is
@@ -1110,8 +1125,14 @@
 
 ]
 
-See also @racket[shell].}
+See also @racket[shell].
 
+@history[#:changed "1.1" @elem{Pipes created for a process are
+                               explicitly closed when a Zuo will
+                               terminate, and they are closed before
+                               waiting for processes to exit.}]}
+
+
 @defproc[(process-wait [process handle?] ...) handle?]{
 
 Waits until the process represented by a @racket[process] has
@@ -1315,9 +1336,13 @@
 @item{@racket['can-exec?]: a boolean whether @racket[process] supports
       a true value for the @racket['exec?] option}
 
-@item{@racket['version]: Zuo's version number as an integer}
+@item{@racket['version]: Zuo's major version number as an integer}
 
-]}
+@item{@racket['minor-version]: Zuo's minor version number as an integer}
+
+]
+
+@history[#:changed "1.1" @elem{Added @racket['minor-version].}]}
 
 @defproc[(system-type) symbol?]{
 
--- a/zuo-doc/overview.scrbl
+++ b/zuo-doc/overview.scrbl
@@ -33,21 +33,39 @@
 will do the same thing as @exec{make} and @exec{make install} with
 a default configuration.
 
-The Zuo executable runs only modules. If you run Zuo with no
-command-line arguments, then it loads @filepath{main.zuo} in the
-current directory. Otherwise, the first argument to Zuo is a file to
-run or a directory containing a @filepath{main.zuo} to run, and
-additional arguments are delivered to that program via the
-@racket[runtime-env] procedure. Either way, if this initial script has
-a @racketidfont{main} submodule, the submodule is run.
+The Zuo executable runs only modules:
 
-Note that starting Zuo with the argument @filepath{.} equivalent to
-the argument @filepath{./main.zuo}, which is a convenient shorthand
-for using @exec{zuo} as a replacement for @exec{make} while still
-passing arguments. When Zuo receives the empty string (which would be
-invalid as a file path) as a first argument, it reads a module from
-standard input.
+@itemlist[
 
+ @item{If you run Zuo with no command-line arguments, then it loads
+       @filepath{main.zuo} in the current directory.}
+
+ @item{As long as the @Flag{c} is not used and the first argument is
+       not the empty string, the first argument to Zuo is used as a
+       file to run or a directory containing a @filepath{main.zuo} to
+       run.
+
+       Note that starting Zuo with the argument @filepath{.} is
+       equivalent to the argument @filepath{./main.zuo}, so @exec{zuo
+       .} is a convenient replacement for @exec{make} while still
+       passing arguments.}
+
+ @item{If the @Flag{c} flag is provided to Zuo, the first argument is
+       treated as the text of a module to run, instead of the name of
+       a file or directory.}
+
+ @item{If the first argument to Zuo is the empty string (which would
+       be invalid as a file path), the module to run is read from
+       standard input.}
+
+]
+
+Additional Zuo arguments are delivered to that program via the
+@racket[runtime-env] procedure. When the initial script module has a
+@racketidfont{main} submodule (see @racket[module+]), that submodule
+is run.
+
+@history[#:changed "1.1" @elem{Added the @Flag{c} flag.}]
 
 @section{Library Modules and Startup Performance}
 
--- a/zuo-doc/zuo-build.scrbl
+++ b/zuo-doc/zuo-build.scrbl
@@ -42,6 +42,48 @@
 @racket[build/dep] to build or register a dependency that is
 discovered in the process of building.
 
+Here's an example of a Zuo script to build @filepath{demo} by
+compiling and linking @filepath{main.c} and @filepath{helper.c}:
+
+@racketblock[
+@#,hash-lang[] zuo
+
+(provide-targets targets-at)
+
+(define (targets-at at-dir vars)
+  (define demo (at-dir (.exe "demo")))
+
+  (define main.c (at-source "main.c"))
+  (define main.o (at-dir (.c->.o "main.c")))
+
+  (define helper.c (at-source "helper.c"))
+  (define helper.o (at-dir (.c->.o "helper.c")))
+
+  (make-targets
+   `([:target ,demo (,main.o ,helper.o)
+              ,(lambda (dest token)
+                 (c-link dest (list main.o helper.o) vars))]
+     [:target ,main.o (,main.c)
+              ,(lambda (dest token)
+                 (c-compile dest main.c vars))]
+     [:target ,helper.o (,helper.c)
+              ,(lambda (dest token)
+                 (c-compile dest helper.c vars))]
+     [:target clean ()
+              ,(lambda (token)
+                 (for-each rm* (list main.o helper.o demo)))])))
+]
+
+Although the @racket[make-targets] function takes a makefile-like
+description of targets and dependencies, this script is still much
+more verbose than a Unix-specific makefile that performs the same
+task. Zuo is designed to support the kind of syntactic abstraction
+that could make this script compact, but the current implementation is
+aimed at build tasks that are larger and more complex. In those cases,
+it's not just a matter of dispatching to external tools like a C
+compiler, and most Zuo code ends up in helper functions and libraries
+outside the @racket[make-targets] form.
+
 @section[#:tag "make-target"]{Creating Targets}
 
 Construct a @deftech{target} with either @racket[input-file-target]
@@ -206,8 +248,9 @@
 so can enable parallelism among targets, depending on the
 @racket['jobs] option provided to @racket[build] or
 @racket[build/command-line], a @DFlag{jobs} command-line argument
-parsed by @racket[build/command-line], or the @envvar{ZUO_JOBS}
-environment variable.
+parsed by @racket[build/command-line], a jobserver configuration as
+provided by GNU make and communicated through the @envvar{MAKEFLAGS}
+environment variable, or the @envvar{ZUO_JOBS} environment variable.
 
 When calling @racket[build] for a nested build from a target's
 @racket[_get-deps] or @racket[_rebuild] procedures, supply the
@@ -388,9 +431,11 @@
 @item{@racket['jobs] mapped to a positive integer: controls the
       maximum build steps that are allowed to proceed concurrently,
       and this concurrency turns into parallelism when a task uses a
-      process and @racket[thread-process-wait]; the @envvar{ZUO_JOBS}
-      environment variable determines the default if it is set,
-      otherwise the default is 1}
+      process and @racket[thread-process-wait]; if @racket['jobs] is
+      not mapped, a jobserver is used if found via
+      @racket[maybe-jobserver-client]; otherwise, the default is the
+      value of the @envvar{ZUO_JOBS} environment variable if it is
+      set, @racket[1] if not}
 
 @item{@racket['log?] mapped to any value: enables logging of rebuild
       reasons via @racket[alert] when the value is not @racket[#f];
@@ -412,7 +457,11 @@
 triggered actions are separate, and @tech{phony} targets are similarly
 triggered independently. Use @racket[build/dep] or
 @racket[build/no-dep], instead, to recursively trigger targets within
-the same build.}
+the same build.
+
+@history[#:changed "1.1" @elem{Use @racket[maybe-jobserver-client] if
+                               @racket['jobs] is not set in
+                               @racket[options].}]}
 
 
 @defproc[(build/dep [target (or target? path-string?)] [token token?]) void?]{
--- a/zuo-doc/zuo-lib.scrbl
+++ b/zuo-doc/zuo-lib.scrbl
@@ -333,3 +333,49 @@
 After reading @racket[file], keys from @racket[overrides] are merged
 to the result hash table, where values in @racket[overrides] replace
 ones read from @racket[file].}
+
+@; ------------------------------------------------------------
+
+@section[#:tag "zuo-jobserver"]{Jobserver Client}
+
+@defzuomodule[zuo/jobserver]
+
+@history[#:added "1.1"]
+
+@defproc[(maybe-jobserver-client) (or/c procedure? #f)]{
+
+Returns a procedure if a jobserver configuration is found via the
+@envvar{MAKEFLAGS} environment variable, @racket[#f] otherwise. That
+environment variable is normally set by GNU Make when it runs a target
+command and when @Flag{j} was provided to @exec{make}. A jobserver
+configuration allows parallelism to span @exec{make} and other
+processes, such as a @exec{zuo} process, through a shared pool of
+jobserver tokens. In other words, a @Flag{j} flag to @exec{make} gets
+propagated to @exec{zuo}.
+
+When a procedure is returned, it accepts one argument: @racket['get]
+or @racket['put]. Apply the procedure with @racket['get] to acquire a
+jobserver token, and apply the procedure with @racket['put] to release
+a previously acquired token. The implicit jobserver token that belongs
+to the @exec{zuo} process should be taken explicitly with
+@racket['get] and released with @racket['put].
+
+The @racket[maybe-jobserver-client] procedure must be called in a
+@tech{threading context}. When it returns a procedure, that procedure
+must also be called (with @racket['get] or @racket['put]) in the same
+threading context.}
+
+@defproc[(maybe-jobserver-jobs) (or/c integer? #f)]{
+
+Similar to @racket[maybe-jobserver-client], but polls the jobserver
+(if any) to determine how many job tokens appear to be immediately
+available. The result is that number, or @racket[#f] if no jobserver
+configuration is found.
+
+Using @racket[maybe-jobserver-client] to cooperate interactively with
+the jobserver is normally better, but @racket[maybe-jobserver-jobs]
+can be useful to chaining to another tool that accepts job count as a
+number.
+
+Unlike @racket[maybe-jobserver-client], @racket[maybe-jobserver-jobs]
+does not need to be called in a @tech{threading context}.}
--- a/zuo.c
+++ b/zuo.c
@@ -3,6 +3,7 @@
    declarations. */
 
 #define ZUO_VERSION 1
+#define ZUO_MINOR_VERSION 1
 
 #if defined(_MSC_VER) || defined(__MINGW32__)
 # define ZUO_WINDOWS
@@ -25,6 +26,7 @@
 # include <time.h>
 # include <dirent.h>
 # include <signal.h>
+# include <poll.h>
 #endif
 #ifdef ZUO_WINDOWS
 # include <windows.h>
@@ -78,6 +80,8 @@
 typedef HANDLE zuo_raw_handle_t;
 #endif
 
+#define ZUO_HANDLE_ID(h) ((zuo_int_t)(h))
+
 /* the "image.zuo" script looks for this line: */
 #define EMBEDDED_IMAGE 0
 
@@ -343,10 +347,10 @@
     zuo_t *o_pending_modules;
 
 #ifdef ZUO_UNIX
-    /* process status table and fd table */
+    /* process status table */
     zuo_t *o_pid_table;
-    zuo_t *o_fd_table;
 #endif
+    zuo_t *o_fd_table;
     zuo_t *o_cleanable_table;
 
     /* startup info */
@@ -1323,7 +1327,7 @@
 
   first = last = z.o_null;
   while ((left != z.o_null) && (right != z.o_null)) {
-    zuo_t *p, *s_left, *s_right;
+    zuo_t *p;
 
     if (strcmp(ZUO_STRING_PTR(((zuo_symbol_t *)_zuo_car(left))->str),
                ZUO_STRING_PTR(((zuo_symbol_t *)_zuo_car(right))->str))
@@ -4055,6 +4059,7 @@
 
 static zuo_t *zuo_finish_runtime_env(zuo_t *ht) {
   ht = zuo_hash_set(ht, zuo_symbol("version"), zuo_integer(ZUO_VERSION));
+  ht = zuo_hash_set(ht, zuo_symbol("minor-version"), zuo_integer(ZUO_MINOR_VERSION));
 
   ht = zuo_hash_set(ht, zuo_symbol("dir"), zuo_current_directory());
   ht = zuo_hash_set(ht, zuo_symbol("env"), zuo_get_envvars());
@@ -4126,12 +4131,12 @@
 # define EINTR_RETRY(e) do { } while (((e) == -1) && (errno == EINTR))
 #endif
 
-static zuo_t *zuo_fd_handle(zuo_raw_handle_t handle, zuo_handle_status_t status)  {
+static zuo_t *zuo_fd_handle(zuo_raw_handle_t handle, zuo_handle_status_t status, int is_pipe) {
   zuo_t *h = zuo_handle(handle, status);
-#ifdef ZUO_UNIX
-  int added = 0;
-  Z.o_fd_table = trie_extend(Z.o_fd_table, handle, h, h, &added);
-#endif
+  {
+    int added = 0;
+    Z.o_fd_table = trie_extend(Z.o_fd_table, ZUO_HANDLE_ID(handle), h, is_pipe ? z.o_true : z.o_false, &added);
+  }
   return h;
 }
 
@@ -4255,9 +4260,7 @@
 static void zuo_close(zuo_raw_handle_t handle)
 {
   zuo_close_handle(handle);
-#ifdef ZUO_UNIX
-  Z.o_fd_table = trie_remove(Z.o_fd_table, handle, 0);
-#endif
+  Z.o_fd_table = trie_remove(Z.o_fd_table, ZUO_HANDLE_ID(handle), 0);
 }
 
 static zuo_raw_handle_t zuo_fd_open_input_handle(zuo_t *path, zuo_t *options) {
@@ -4402,7 +4405,7 @@
 	SetFilePointer(fd, 0, NULL, FILE_END);
     }
 #endif
-    fd_h = zuo_fd_handle(fd, zuo_handle_open_fd_out_status);
+    fd_h = zuo_fd_handle(fd, zuo_handle_open_fd_out_status, 0);
 
     return fd_h;
   } else if (path == zuo_symbol("stdout")) {
@@ -4432,18 +4435,23 @@
   }
 }
 
-static void zuo_check_input_output_fd(const char *who, zuo_t *fd_h) {
+static int zuo_is_input_output_fd(zuo_t *fd_h) {
   if (fd_h->tag == zuo_handle_tag) {
     zuo_handle_t *h = (zuo_handle_t *)fd_h;
     if ((h->u.h.status == zuo_handle_open_fd_out_status)
         || (h->u.h.status == zuo_handle_open_fd_in_status)) {
-      return;
+      return 1;
     }
   }
 
-  zuo_fail_arg(who, "open input or output file descriptor", fd_h);
+  return 0;
 }
 
+static void zuo_check_input_output_fd(const char *who, zuo_t *fd_h) {
+  if (!zuo_is_input_output_fd(fd_h))
+    zuo_fail_arg(who, "open input or output file descriptor", fd_h);
+}
+
 static zuo_t *zuo_fd_close(zuo_t *fd_h) {
   zuo_check_input_output_fd("fd-close", fd_h);
   {
@@ -4494,6 +4502,103 @@
   return zuo_drain(ZUO_HANDLE_RAW(fd_h), amt);
 }
 
+zuo_t *zuo_fd_poll(zuo_t *fds_i, zuo_t *timeout_i) {
+  const char *who = "fd-poll";
+  zuo_t *l;
+  zuo_int_t len = 0, which = -1, timeout;
+#ifdef ZUO_UNIX
+  struct pollfd *fds;
+#endif
+#ifdef ZUO_WINDOWS
+  HANDLE *fds;
+#endif
+
+  for (l = fds_i; l != z.o_null; l = _zuo_cdr(l)) {
+    if ((l->tag != zuo_pair_tag)
+        || !zuo_is_input_output_fd(_zuo_car(l)))
+      zuo_fail_arg(who, "list of open input and output file descriptor handles", fds_i);
+    len++;
+  }
+
+  if ((timeout_i == z.o_undefined) || (timeout_i == z.o_false))
+    timeout = -1;
+  else if ((timeout_i->tag == zuo_integer_tag)
+           && (ZUO_INT_I(timeout_i) >= 0))
+    timeout = ZUO_INT_I(timeout_i);
+  else
+    zuo_fail_arg(who, "nonnegative integer or #f", timeout_i);
+
+#ifdef ZUO_UNIX
+  /* loop until on of the handles is marked as done */
+  {
+    zuo_int_t i;
+    int ready;
+
+    fds = malloc(sizeof(struct pollfd) * len);
+    for (l = fds_i, i = 0; l != z.o_null; l = _zuo_cdr(l), i++) {
+      zuo_handle_t *h = (zuo_handle_t *)_zuo_car(l);
+      fds[i].fd = h->u.h.u.handle;
+      if (h->u.h.status == zuo_handle_open_fd_out_status)
+        fds[i].events = POLLOUT;
+      else
+        fds[i].events = POLLIN;
+      fds[i].revents = 0;
+    }
+
+    /* wait for any process to exit, and update the corresponding handle */
+    EINTR_RETRY(ready = poll(fds, len, timeout));
+
+    if (ready > 0) {
+      for (i = 0; i < len; i++) {
+        if (fds[i].revents != 0) {
+          which = i;
+          break;
+        }
+      }
+    } else if (ready == 0)
+      which = len;
+    else
+      zuo_fail1w_errno(who, "poll failed", fds_i);
+  }
+#endif
+#ifdef ZUO_WINDOWS
+  if (len == 0) {
+    if (timeout != 0)
+      Sleep(timeout < 0 ? INFINITE : timeout);
+    which = len;
+  } else {
+    zuo_int_t i = 0;
+    DWORD r;
+
+    fds = malloc(sizeof(HANDLE) * len);
+
+    for (l = fds_i; l != z.o_null; l = _zuo_cdr(l)) {
+      zuo_handle_t *h = (zuo_handle_t *)_zuo_car(l);
+      fds[i++] = h->u.h.u.handle;
+    }
+
+    r = WaitForMultipleObjects(len, fds, FALSE, timeout < 0 ? INFINITE : timeout);
+
+    if (r == WAIT_TIMEOUT)
+      which = len;
+    else if (r != WAIT_FAILED)
+      which = r - WAIT_OBJECT_0;
+    else
+      zuo_fail1w(who, "poll failed", fds_i);
+  }
+#endif
+
+  for (l = fds_i; which > 0; l = _zuo_cdr(l))
+    which--;
+
+  free(fds);
+
+  if (l == z.o_null)
+    return z.o_false;
+  else
+    return _zuo_car(l);
+}
+
 static zuo_t *zuo_fd_terminal_p(zuo_t *fd_h, zuo_t *ansi) {
   zuo_check_input_output_fd("fd-ansi-terminal?", fd_h);
   if ((ansi != z.o_undefined) && (ansi != z.o_false) && !zuo_ansi_ok)
@@ -5302,7 +5407,7 @@
 }
 
 static void zuo_clean_all() {
-  zuo_t *keys, *l;
+  zuo_t *keys, *l, *open_fds;
 
   if (Z.o_cleanable_table == z.o_undefined)
     return; /* must be an error during startup */
@@ -5311,6 +5416,14 @@
 
   keys = zuo_trie_keys(Z.o_cleanable_table, z.o_null);
 
+  /* close pipes connected to processes, so they'll know we're trying to exit */
+  open_fds = zuo_trie_keys(Z.o_fd_table, z.o_null);
+  for (l = open_fds; l != z.o_null; l = _zuo_cdr(l)) {
+    zuo_handle_t *h = (zuo_handle_t *)_zuo_car(l);
+    if (trie_lookup(Z.o_fd_table, ZUO_HANDLE_ID(h->u.h.u.handle)) == z.o_true)
+      zuo_close(h->u.h.u.handle);
+  }
+
   /* wait for all processes */
   for (l = keys; l != z.o_null; l = _zuo_cdr(l)) {
     zuo_t *k = _zuo_car(l);
@@ -6078,11 +6191,11 @@
   result = z.o_empty_hash;
   result = zuo_hash_set(result, zuo_symbol("process"), p_handle);
   if (redirect_in)
-    result = zuo_hash_set(result, zuo_symbol("stdin"), zuo_fd_handle(in, zuo_handle_open_fd_out_status));
+    result = zuo_hash_set(result, zuo_symbol("stdin"), zuo_fd_handle(in, zuo_handle_open_fd_out_status, 1));
   if (redirect_out)
-    result = zuo_hash_set(result, zuo_symbol("stdout"), zuo_fd_handle(out, zuo_handle_open_fd_in_status));
+    result = zuo_hash_set(result, zuo_symbol("stdout"), zuo_fd_handle(out, zuo_handle_open_fd_in_status, 1));
   if (redirect_err)
-    result = zuo_hash_set(result, zuo_symbol("stderr"), zuo_fd_handle(err, zuo_handle_open_fd_in_status));
+    result = zuo_hash_set(result, zuo_symbol("stderr"), zuo_fd_handle(err, zuo_handle_open_fd_in_status, 1));
 
   return result;
 }
@@ -6993,6 +7106,7 @@
   ZUO_TOP_ENV_SET_PRIMITIVE1("fd-close", zuo_fd_close);
   ZUO_TOP_ENV_SET_PRIMITIVE2("fd-read", zuo_fd_read);
   ZUO_TOP_ENV_SET_PRIMITIVE2("fd-write", zuo_fd_write);
+  ZUO_TOP_ENV_SET_PRIMITIVEb("fd-poll", zuo_fd_poll);
   ZUO_TOP_ENV_SET_PRIMITIVEb("fd-terminal?", zuo_fd_terminal_p);
   ZUO_TOP_ENV_SET_PRIMITIVE1("fd-valid?", zuo_fd_valid_p);
 
@@ -7100,8 +7214,8 @@
 
 #ifdef ZUO_UNIX
   Z.o_pid_table = z.o_empty_hash;
-  Z.o_fd_table = z.o_empty_hash;
 #endif
+  Z.o_fd_table = z.o_empty_hash;
   Z.o_cleanable_table = z.o_empty_hash;
 
   Z.o_library_path = lib_path; /* should be absolute or #f */
@@ -7119,6 +7233,7 @@
   char *load_file = NULL, *library_path = NULL, *boot_image = NULL;
   char *argv0 = argv[0];
   zuo_t *exe_path, *load_path, *lib_path;
+  int eval_argument = 0;
 
   argc--;
   argv++;
@@ -7131,6 +7246,7 @@
                        "If <file-or-dir> is a file, it is used as a module path to load.\n"
                        "If <file-or-dir> is a directory, \"main.zuo\" is loded.\n"
                        "If <file-or-dir> is \"\", a module is read from stdin.\n"
+                       "But if `-c` is provided, <file-or-dir> is parsed as a module.\n"
                        "The <argument>s are made available via the `system-env` procedure.\n"
                        "\n"
                        "Supported <option>s:\n"
@@ -7142,6 +7258,9 @@
                        "     the default is \"%s\" relative to the executable\n"
                        "  -M <file>\n"
                        "     Log the path of each opened file to <file>\n"
+                       "  -c\n"
+                       "     Use <file-or-dir> as module text, instead of the name of\n"
+                       "     a file or directory\n"
                        "  --\n"
                        "     No argument following this switch is used as a switch\n"
                        "  -h, --help\n"
@@ -7186,6 +7305,10 @@
         fprintf(stderr, "%s: expected a path after -M", argv0);
         zuo_fail("");
       }
+    } else if (!strcmp(argv[0], "-c")) {
+      eval_argument = 1;
+      argc--;
+      argv++;
     } else if (!strcmp(argv[0], "--")) {
       argc--;
       argv++;
@@ -7202,6 +7325,10 @@
     load_file = argv[0];
     argc--;
     argv++;
+  } else if (eval_argument) {
+    zuo_error_color();
+    fprintf(stderr, "%s: expected an argument after -c", argv0);
+    zuo_fail("");
   }
 
   /* Primitives must be registered before restoring an image */
@@ -7228,6 +7355,8 @@
       fprintf(stderr, "%s: no file specified, and no \"main.zuo\" found", argv0);
       zuo_fail("");
     }
+  } else if (eval_argument) {
+    load_path = zuo_string("argument");
   } else if (load_file[0] != 0) {
     zuo_t *st;
     load_path = zuo_string(load_file);
@@ -7250,7 +7379,9 @@
   {
     zuo_t *mod_ht, *submods, *main_proc;
 
-    if (load_file[0] == 0) {
+    if (eval_argument) {
+      mod_ht = zuo_eval_module(load_path, zuo_string(load_file));
+    } else if (load_file[0] == 0) {
       zuo_raw_handle_t in = zuo_fd_open_input_handle(zuo_symbol("stdin"), z.o_empty_hash);
       zuo_t *input = zuo_drain(in, -1);
       mod_ht = zuo_eval_module(load_path, input);