Skip to content

Windows: process spawning support - #936

Open
avsm wants to merge 4 commits into
ocaml-multicore:mainfrom
avsm:win-spawn
Open

Windows: process spawning support#936
avsm wants to merge 4 commits into
ocaml-multicore:mainfrom
avsm:win-spawn

Conversation

@avsm

@avsm avsm commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

The long awaited Windows process spawn after 2 unsuccessful other attempts that I didn't open a PR for! This is layered over #929 as we need pipes to work for tests.

This doesn't use Unix.create_process since I found it tricky to integrate:

  • The stdlib one inherits every fd in the process, so (e.g.) pipes leak. This PR follows the Eio style and restricts inheritance to the three standard handles + an explicit inheritance list.
  • This one allows the cwd to be set via the Eio sandbox
  • We can cancel without blocking the domain here since we have a systhread for it.

Note that any signal sent will terminate the process. Should we actually parse the signal numbers and do something 'terminal' only on SIGKILL/TERM?

Another difference from Posix is that CreateProcess resolves the executable path itself if its blank, but I could replicate the PATH search that we do in Posix (Windows seems to prefer the search to be done by the call, I'm not sure).

We may need to restrict to OCaml 5.4 or higher since that's when @dra27 added caml_stat_char_array_to_utf16

Caveat: I am very inexperienced in these APIs so this is the results of much rooting around in MSDN, but I am happy to stand corrected on any of these decisions. This does progress the Forester test suite due to it spawning xcopy quite significantly though! :-)

avsm added 4 commits September 9, 2026 10:34
On Windows,an anonymous pipe cant be made non-blocking, so
this call always failed with an ENOTSOCK for me.
We now use a pool thread for blocking reads and writes.
@avsm

avsm commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

I'll need to back out the pipe changes here (see #929) , but the rest of the process spawning is still in reasonable shape.

@talex5 talex5 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a quick review (I know it needs updating to remove the pipe stuff, so I've ignored those bits).

I think the logic for waiting for the child isn't quite right, and could probably be simplified.

The changes to nt_path look generally useful and could be split into another PR if you want to get that out of the way first.

Comment on lines +7 to +10
let read_all flow =
let b = Buffer.create 100 in
Eio.Flow.copy flow (Eio.Flow.buffer_sink b);
Buffer.contents b

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just Eio.Flow.read_all.

Comment on lines +12 to +15
let check_status msg expected = function
| `Exited code when code = expected -> ()
| status ->
Alcotest.failf "%s: expected exit %d, got %a" msg expected Process.pp_status status

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could Alcotest.of_pp help here? I'd expect something like:

let status = Alcotest.of_pp Eio.Process.pp_status

That would also eliminate the need for a separate check_signalled below.

check_status "findstr" 0 (Process.await child);
Alcotest.(check string) "roundtrip" "hello" (String.trim out)

(* A handle leaking into a sibling would delay its pipe's EOF *)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see how this tests that. Even if another process inherits a pipe, it will just exit quickly anyway and the test will still pass. Maybe this comment belongs to the next test instead?

Switch.run @@ fun sw ->
let r, w = Eio_unix.pipe sw in
let w_fd = Option.get (Eio_unix.Resource.fd_opt w) in
Eio_unix.Fd.use_exn "pipe" w_fd Unix.clear_close_on_exec;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If close_on_exec is clear, then inheriting the FD is the correct behaviour (at least, that's what the other backends do, and it's what I'd expect).

~fds:(std_fds @ [3, Eio_unix.Fd.stdin, `Blocking])
["cmd"; "/c"; "exit"; "0"]))

(* An unlisted descriptor is inherited, as on Unix. *)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we know this worked? exit doesn't try to use any of the FDs.

Comment on lines +59 to +61
match Promise.await t.exited with
| Ok code -> code
| Error ex -> raise ex

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
match Promise.await t.exited with
| Ok code -> code
| Error ex -> raise ex
Promise.await_exn t.exited

Comment thread lib_eio_windows/sched.mli

When [time] is reached, [k] is resumed. Cancelling [k] removes the entry from the timer. *)

val await_thread : t -> 'a Eio_utils.Suspended.t -> ?finished:(('a, exn) result -> unit) -> (unit -> 'a) -> exit

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This await_thread thing seems really odd. It looks like it waits for the process unless cancelled. But if the spawning switch is cancelled, we want to signal the process and wait for it to exit, so there's no reason to do this.

Maybe something like this:

let watcher = Thread.create (fun h -> Promise.resolve t.exited (eio_process_wait h)) h;

The you can just await t.exited, and signal the process if the switch ends. eio_posix has some suitable logic for this already (might not need reap on Windows, not sure whether calling Thread.join is necessary):

let hook = Switch.on_release_cancellable sw (fun () ->
(* Kill process (if still running) *)
signal t Sys.sigkill;
(* The switch is being released, so either the daemon fiber got
cancelled or it hasn't started yet (and never will start). *)
if not (Promise.is_resolved t.exit_status) then (
(* Do a (non-cancellable) waitpid here to reap the child. *)
reap t set_exit_status
)
) in
Fiber.fork_daemon ~sw (fun () ->
reap t set_exit_status;
Switch.remove_hook hook;
`Stop_daemon
);

(it's slightly complicated because we decided that the switch ending should terminate the child, rather than waiting for it to finish)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants