diff --git a/lib_eio/utils/nt_path.ml b/lib_eio/utils/nt_path.ml index 4c7856b46..ef5643e85 100644 --- a/lib_eio/utils/nt_path.ml +++ b/lib_eio/utils/nt_path.ml @@ -124,15 +124,25 @@ let normalise rest = in "\\" ^ String.concat "\\" (go [] (String.split_on_char '\\' rest)) +(* The rest of [p] after the prefix [r], if [p] has it. *) +let after r p = Option.map (fun i -> drop i p) (r p 0) + (* [qualify p] is the absolute Win32 path [p] named in the NT namespace. *) let qualify p = - let after r = Option.map (fun i -> drop i p) (r p 0) in "\\??\\" ^ - match after nt_prefix, after (bslash *> bslash) with + match after nt_prefix p, after (bslash *> bslash) p with | Some rest, _ -> rest (* \??\, \\?\ or \\.\ *) | None, Some share -> "UNC\\" ^ share (* \\server\share *) | None, None -> p (* C:\... *) +let to_win32 p = + match after (verbatim_prefix *> bslash) p with + | None -> p (* including \\.\, which Win32 understands *) + | Some rest -> + match after (unc_kw *> bslash) rest with + | Some share -> "\\\\" ^ share (* \\server\share *) + | None -> rest (* C:\... *) + let to_nt ~cwd p = if verbatim p then qualify p else ( diff --git a/lib_eio/utils/nt_path.mli b/lib_eio/utils/nt_path.mli index 719e356ee..b13da3181 100644 --- a/lib_eio/utils/nt_path.mli +++ b/lib_eio/utils/nt_path.mli @@ -22,6 +22,12 @@ val basename : string -> string (** [basename p] is the final component of [p]. It is [p] itself when [p] has no directory part, and ["."] when [p] is empty. *) +val to_win32 : string -> string +(** [to_win32 p] is [p] without any verbatim ([\\?\]) or NT ([\??\]) prefix, + for programs that cannot handle one. It undoes {!to_nt}: [to_win32 (to_nt ~cwd p)] + is the absolute Win32 form of [p]. A device path ([\\.\]) is left alone, + as Win32 understands it. *) + val to_nt : cwd:string -> string -> string (** [to_nt ~cwd path] is the NT object-manager form of the Win32 path [path]. diff --git a/lib_eio_windows/eio_windows.ml b/lib_eio_windows/eio_windows.ml index d8f2a931f..c8d806e9f 100755 --- a/lib_eio_windows/eio_windows.ml +++ b/lib_eio_windows/eio_windows.ml @@ -33,7 +33,7 @@ let run main = method domain_mgr = Domain_mgr.v method cwd = (Fs.cwd :> Eio.Fs.dir_ty Eio.Path.t) method fs = (Fs.fs :> Eio.Fs.dir_ty Eio.Path.t) - method process_mgr = failwith "process operations not supported on Windows yet" + method process_mgr = Process.mgr method secure_random = Flow.secure_random method backend_id = "windows" end diff --git a/lib_eio_windows/eio_windows_stubs.c b/lib_eio_windows/eio_windows_stubs.c index 1f45acbbc..56ecec32e 100755 --- a/lib_eio_windows/eio_windows_stubs.c +++ b/lib_eio_windows/eio_windows_stubs.c @@ -277,7 +277,153 @@ CAMLprim value caml_eio_windows_symlinkat(value v_old_path, value v_new_fd, valu uerror("symlinkat is not supported on windows yet", Nothing); } -CAMLprim value caml_eio_windows_spawn(value v_errors, value v_actions) +/* The block CreateProcess expects: the entries NUL-separated, then a final NUL. */ +static wchar_t *env_block_of_array(value v_env) { - uerror("processes are not supported on windows yet", Nothing); + mlsize_t n = Wosize_val(v_env), i; + size_t total = n == 0 ? 2 : 1; + wchar_t **parts = caml_stat_alloc((n + 1) * sizeof(wchar_t *)); + wchar_t *block, *p; + + for (i = 0; i < n; i++) { + parts[i] = caml_stat_strdup_to_utf16(String_val(Field(v_env, i))); + total += wcslen(parts[i]) + 1; + } + p = block = caml_stat_alloc(total * sizeof(wchar_t)); + for (i = 0; i < n; i++) { + size_t len = wcslen(parts[i]) + 1; + memcpy(p, parts[i], len * sizeof(wchar_t)); + caml_stat_free(parts[i]); + p += len; + } + *p = 0; + if (n == 0) p[1] = 0; + caml_stat_free(parts); + return block; +} + +CAMLprim value caml_eio_windows_spawn(value v_cwd, value v_env, + value v_stdin, value v_stdout, value v_stderr, + value v_cmdline) +{ + CAMLparam5(v_cwd, v_env, v_stdin, v_stdout, v_stderr); + CAMLxparam1(v_cmdline); + CAMLlocal1(v_result); + + wchar_t *cmdline = NULL, *cwd = NULL, *env_block = NULL; + HANDLE src[3]; + HANDLE dup[3] = { NULL, NULL, NULL }; + STARTUPINFOEXW si; + PROCESS_INFORMATION pi; + SIZE_T attr_size = 0; + BOOL ok = FALSE; + DWORD err = 0; + DWORD create_flags = EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT; + HANDLE cur = GetCurrentProcess(); + + memset(&si, 0, sizeof(si)); + memset(&pi, 0, sizeof(pi)); + + caml_unix_check_path(v_cmdline, "execve"); + if (Is_some(v_cwd)) caml_unix_check_path(Field(v_cwd, 0), "execve"); + cmdline = caml_stat_strdup_to_utf16(String_val(v_cmdline)); + if (Is_some(v_cwd)) cwd = caml_stat_strdup_to_utf16(String_val(Field(v_cwd, 0))); + /* Always passed: NULL would make the child inherit our environment. */ + env_block = env_block_of_array(v_env); + + src[0] = Handle_val(v_stdin); + src[1] = Handle_val(v_stdout); + src[2] = Handle_val(v_stderr); + + for (int i = 0; i < 3; i++) { + if (!DuplicateHandle(cur, src[i], cur, &dup[i], 0, TRUE, DUPLICATE_SAME_ACCESS)) { + err = GetLastError(); + goto cleanup; + } + } + + si.StartupInfo.cb = sizeof(STARTUPINFOEXW); + si.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + si.StartupInfo.hStdInput = dup[0]; + si.StartupInfo.hStdOutput = dup[1]; + si.StartupInfo.hStdError = dup[2]; + + if (!GetConsoleWindow()) create_flags |= CREATE_NO_WINDOW; + + InitializeProcThreadAttributeList(NULL, 1, 0, &attr_size); + si.lpAttributeList = caml_stat_alloc(attr_size); + if (!InitializeProcThreadAttributeList(si.lpAttributeList, 1, 0, &attr_size)) { + err = GetLastError(); + caml_stat_free(si.lpAttributeList); + si.lpAttributeList = NULL; + goto cleanup; + } + if (!UpdateProcThreadAttribute(si.lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + dup, 3 * sizeof(HANDLE), NULL, NULL)) { + err = GetLastError(); + goto cleanup; + } + + caml_enter_blocking_section(); + ok = CreateProcessW(NULL, cmdline, NULL, NULL, TRUE, create_flags, env_block, cwd, + &si.StartupInfo, &pi); + if (!ok) err = GetLastError(); + caml_leave_blocking_section(); + +cleanup: + if (si.lpAttributeList) { + DeleteProcThreadAttributeList(si.lpAttributeList); + caml_stat_free(si.lpAttributeList); + } + for (int i = 0; i < 3; i++) + if (dup[i]) CloseHandle(dup[i]); + caml_stat_free(cmdline); + caml_stat_free(cwd); + caml_stat_free(env_block); + + if (!ok) { + caml_win32_maperr(err); + /* Named "execve" so the portable error translation applies unchanged. */ + uerror("execve", v_cmdline); + } + + CloseHandle(pi.hThread); + v_result = caml_alloc_tuple(2); + Store_field(v_result, 0, Val_long(pi.dwProcessId)); + Store_field(v_result, 1, caml_win32_alloc_handle(pi.hProcess)); + CAMLreturn(v_result); +} + +CAMLprim value caml_eio_windows_spawn_bytes(value *argv, int argn) +{ + (void)argn; + return caml_eio_windows_spawn(argv[0], argv[1], argv[2], argv[3], + argv[4], argv[5]); +} + +CAMLprim value caml_eio_windows_process_wait(value v_handle) +{ + CAMLparam1(v_handle); + HANDLE h = Handle_val(v_handle); + DWORD code = 0; + DWORD wait_res; + caml_enter_blocking_section(); + wait_res = WaitForSingleObject(h, INFINITE); + caml_leave_blocking_section(); + if (wait_res == WAIT_FAILED) { + caml_win32_maperr(GetLastError()); + uerror("process_wait", Nothing); + } + if (!GetExitCodeProcess(h, &code)) { + caml_win32_maperr(GetLastError()); + uerror("process_wait", Nothing); + } + CAMLreturn(Val_long((intnat)(unsigned int)code)); +} + +CAMLprim value caml_eio_windows_process_terminate(value v_handle, value v_code) +{ + CAMLparam2(v_handle, v_code); + /* Signalling an already-exited process is a no-op rather than an error. */ + CAMLreturn(Val_bool(TerminateProcess(Handle_val(v_handle), (UINT)Long_val(v_code)))); } diff --git a/lib_eio_windows/include/discover.ml b/lib_eio_windows/include/discover.ml index b38c130a6..a1801131b 100755 --- a/lib_eio_windows/include/discover.ml +++ b/lib_eio_windows/include/discover.ml @@ -35,6 +35,9 @@ let () = "FILE_NO_INTERMEDIATE_BUFFERING", Int; "FILE_WRITE_THROUGH", Int; "FILE_SEQUENTIAL_ONLY", Int; + + (* Exit Codes *) + "STATUS_CONTROL_C_EXIT", Int; ] |> List.map (function | name, C.C_define.Value.Int v -> diff --git a/lib_eio_windows/low_level.ml b/lib_eio_windows/low_level.ml index 3d087c311..a3ef45806 100755 --- a/lib_eio_windows/low_level.ml +++ b/lib_eio_windows/low_level.ml @@ -38,25 +38,27 @@ let rec do_nonblocking ty fn fd = ); do_nonblocking ty fn fd +let transfer label ty op fd = + match Fd.is_blocking fd with + | true -> + Fd.use_exn label fd @@ fun fd -> + in_worker_thread ~label (fun () -> op fd) + | false -> + (match ty with Read -> await_readable fd | Write -> await_writable fd); + Fd.use_exn label fd @@ fun fd -> + do_nonblocking ty op fd + let read fd buf start len = - await_readable fd; - Fd.use_exn "read" fd @@ fun fd -> - do_nonblocking Read (fun fd -> Unix.read fd buf start len) fd + transfer "read" Read (fun fd -> Unix.read fd buf start len) fd let read_cstruct fd (buf:Cstruct.t) = - await_readable fd; - Fd.use_exn "read_cstruct" fd @@ fun fd -> - do_nonblocking Read (fun fd -> Unix.read_bigarray fd buf.buffer buf.off buf.len) fd + transfer "read_cstruct" Read (fun fd -> Unix.read_bigarray fd buf.buffer buf.off buf.len) fd let write fd buf start len = - await_writable fd; - Fd.use_exn "write" fd @@ fun fd -> - do_nonblocking Write (fun fd -> Unix.write fd buf start len) fd + transfer "write" Write (fun fd -> Unix.write fd buf start len) fd let write_cstruct fd (buf:Cstruct.t) = - await_writable fd; - Fd.use_exn "write_cstruct" fd @@ fun fd -> - do_nonblocking Write (fun fd -> Unix.write_bigarray fd buf.buffer buf.off buf.len) fd + transfer "write_cstruct" Write (fun fd -> Unix.write_bigarray fd buf.buffer buf.off buf.len) fd let sleep_until time = Sched.enter @@ fun t k -> @@ -305,8 +307,6 @@ let ftruncate fd len = let pipe ~sw = let unix_r, unix_w = Unix.pipe ~cloexec:true () in - let r = Fd.of_unix ~sw ~blocking:false ~close_unix:true unix_r in - let w = Fd.of_unix ~sw ~blocking:false ~close_unix:true unix_w in - Unix.set_nonblock unix_r; - Unix.set_nonblock unix_w; + let r = Fd.of_unix ~sw ~blocking:true ~close_unix:true unix_r in + let w = Fd.of_unix ~sw ~blocking:true ~close_unix:true unix_w in r, w diff --git a/lib_eio_windows/process.ml b/lib_eio_windows/process.ml new file mode 100644 index 000000000..3d221acf6 --- /dev/null +++ b/lib_eio_windows/process.ml @@ -0,0 +1,142 @@ +open Eio.Std + +module Fd = Eio_unix.Fd + +external eio_spawn : + string option -> string array -> + Unix.file_descr -> Unix.file_descr -> Unix.file_descr -> + string -> int * Unix.file_descr + = "caml_eio_windows_spawn_bytes" "caml_eio_windows_spawn" + +external eio_process_wait : Unix.file_descr -> int = "caml_eio_windows_process_wait" +external eio_process_terminate : Unix.file_descr -> int -> bool = "caml_eio_windows_process_terminate" + +let check_env env = + Array.iter + (fun s -> + if s = "" || String.contains s '\000' then + Fmt.invalid_arg "spawn: invalid environment entry %S" s) + env; + env + +(* Follow OCaml stdlib and only quote only where needed, as apparently cmd.exe + treats quoted arguments differently even if they dont have spaces. *) +let command_line args = + let quote_arg arg = + if arg = "" || String.exists (function ' ' | '\t' | '\n' | '\011' | '"' -> true | _ -> false) arg + then Filename.quote arg + else arg in + String.concat " " (List.map quote_arg args) + +let terminated_exit_code = Config.status_control_c_exit + +module Process = struct + type t = { + pid : int; + handle : Fd.t; + mutable signalled : int option; + exited : (int, exn) result Promise.t; + resolve : (int, exn) result Promise.u; + mutable waiting : bool; + mutable hook : Switch.hook; (* Removed once the process has been reaped. *) + } + type tag = [ `Generic | `Unix ] + + let pid t = t.pid + + let start_waiting t = + t.waiting <- true; + Fd.use_exn "process_wait" t.handle @@ fun h -> + ignore ( + Sched.enter (fun sched k -> + Sched.await_thread sched k ~finished:(Promise.resolve t.resolve) + (fun () -> eio_process_wait h)) + : int) + + let await t = + if not t.waiting then start_waiting t; + let code = + match Promise.await t.exited with + | Ok code -> code + | Error ex -> raise ex + in + ignore (Switch.try_remove_hook t.hook : bool); + t.hook <- Switch.null_hook; + match t.signalled with + | Some signum when code = terminated_exit_code -> `Signaled signum + | _ -> `Exited code + + (* Windows has no signals: any signal terminates the process. *) + let signal t signum = + Fd.use t.handle ~if_closed:ignore (fun h -> + if eio_process_terminate h terminated_exit_code then t.signalled <- Some signum) + + let stop t = + Eio.Cancel.protect @@ fun () -> + signal t Sys.sigkill; + ignore (await t : Eio.Process.exit_status) +end + +let process_handler = Eio.Process.Pi.process (module Process) +let process t = Eio.Resource.T (t, process_handler) + +module Impl = struct + module T = struct + type t = unit + + (* CreateProcess takes a Win32 path and not NT object, so we must restrict this. + It may be possible to use NtCreateUserProcess directly in the future but + the details are pretty complicated so I didnt go into it just yet. *) + let cwd_path ((dir, path) : Eio.Fs.dir_ty Eio.Path.t) = + match Fs.Handler.as_posix_dir dir with + | None -> Fmt.invalid_arg "cwd is not an eio_windows directory!" + | Some d -> + Eio_utils.Nt_path.to_win32 (Err.run Low_level.realpath (Fs.Dir.resolve d path)) + + let spawn_unix () ~sw ?cwd ?pgid ?uid ?gid ?login_tty ~env ~fds ~executable args = + if pgid <> None || uid <> None || gid <> None then + Fmt.invalid_arg "spawn: pgid/uid/gid are not supported on Windows"; + if login_tty <> None then + Fmt.invalid_arg "spawn: login_tty is not supported on Windows"; + List.iter (fun (i, _, _) -> + if i > 2 then Fmt.invalid_arg "spawn: only fds 0-2 are supported on Windows (got fd %d)" i) + fds; + let cmdline = + command_line (executable :: (match args with [] -> [] | _ :: tl -> tl)) + in + let env = check_env env in + (* Anything not listed is inherited, as on POSIX. *) + let get n default = + Option.value ~default (List.find_map (fun (i, fd, _) -> if i = n then Some fd else None) fds) + in + let stdin_fd = get 0 Fd.stdin and stdout_fd = get 1 Fd.stdout and stderr_fd = get 2 Fd.stderr in + let cwd = Option.map cwd_path cwd in + let pid, raw_handle = + Fd.use_exn "stdin" stdin_fd @@ fun h0 -> + Fd.use_exn "stdout" stdout_fd @@ fun h1 -> + Fd.use_exn "stderr" stderr_fd @@ fun h2 -> + eio_spawn cwd env h0 h1 h2 cmdline + in + let handle = Fd.of_unix ~sw ~blocking:true ~close_unix:true raw_handle in + let exited, resolve = Promise.create () in + let t = { Process.pid; handle; signalled = None; exited; resolve; + waiting = false; hook = Switch.null_hook } in + t.hook <- Switch.on_release_cancellable sw (fun () -> Process.stop t); + process t + end + + include Eio_unix.Process.Make_mgr (T) + + (* CreateProcess resolves the program itself, so the head of the cmdline is used. *) + let spawn v ~sw ?cwd ?stdin ?stdout ?stderr ?env ?executable args = + let executable = + match executable, args with + | Some x, _ | None, x :: _ -> x + | None, [] -> invalid_arg "Arguments list is empty and no executable given!" + in + spawn v ~sw ?cwd ?stdin ?stdout ?stderr ?env ~executable args +end + +let mgr : Eio_unix.Process.mgr_ty r = + let h = Eio_unix.Process.Pi.mgr_unix (module Impl) in + Eio.Resource.T ((), h) diff --git a/lib_eio_windows/sched.ml b/lib_eio_windows/sched.ml index e93144f78..90ba1037a 100755 --- a/lib_eio_windows/sched.ml +++ b/lib_eio_windows/sched.ml @@ -315,6 +315,23 @@ let await_timeout t (k : unit Suspended.t) time = ); next t +let await_thread t (k : _ Suspended.t) ?(finished=ignore) fn = + match Fiber_context.get_error k.fiber with + | Some e -> Suspended.discontinue k e + | None -> + let resumed = Atomic.make false in + Fiber_context.set_cancel_fn k.fiber (fun ex -> + if not (Atomic.exchange resumed true) then enqueue_failed_thread t k ex + ); + Eio_unix.Private.Thread_pool.submit t.thread_pool ~ctx:k.fiber + ~enqueue:(fun r -> + let r = Result.map_error fst r in + finished r; + if not (Atomic.exchange resumed true) then get_enqueue t k r + ) + fn; + next t + let with_op t fn x = t.active_ops <- t.active_ops + 1; match fn x with diff --git a/lib_eio_windows/sched.mli b/lib_eio_windows/sched.mli index 35a0f2112..a0de555e0 100755 --- a/lib_eio_windows/sched.mli +++ b/lib_eio_windows/sched.mli @@ -38,6 +38,14 @@ val await_timeout : t -> unit Eio_utils.Suspended.t -> Mtime.t -> exit 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 +(** [await_thread t k fn] runs [fn] in a pool thread and resumes [k] with its result. + Cancelling [k] only stops it being resumed: [fn] still runs to completion. + + [finished] is called with the result from the pool thread whether or not [k] + was cancelled, so that work shared by several fibers is not lost with the one + that started it. *) + val enter : (t -> 'a Eio_utils.Suspended.t -> exit) -> 'a (** [enter fn] suspends the current fiber and runs [fn t k] in the scheduler's context. diff --git a/lib_eio_windows/test/test.ml b/lib_eio_windows/test/test.ml index a0c12eedd..dec87cf6f 100755 --- a/lib_eio_windows/test/test.ml +++ b/lib_eio_windows/test/test.ml @@ -85,6 +85,8 @@ let () = Alcotest.run ~bail:true "eio_windows" [ "net", Test_net.tests env; "fs", Test_fs.tests env; + "pipe", Test_pipe.tests; + "process", Test_process.tests env; "timeout", Timeout.tests env; "random", Random.tests env; "dla", Dla.tests; diff --git a/lib_eio_windows/test/test_pipe.ml b/lib_eio_windows/test/test_pipe.ml new file mode 100644 index 000000000..ca170bdc2 --- /dev/null +++ b/lib_eio_windows/test/test_pipe.ml @@ -0,0 +1,40 @@ +(* Tests for anonymous pipes *) + +open Eio.Std + +let read_all flow = + let b = Buffer.create 16 in + Eio.Flow.copy flow (Eio.Flow.buffer_sink b); + Buffer.contents b + +let test_transfer () = + Switch.run @@ fun sw -> + let r, w = Eio_unix.pipe sw in + Eio.Flow.copy_string "hello" w; + Eio.Flow.close w; + Alcotest.(check string) "transfer" "hello" (read_all r) + +let test_read_before_write () = + Switch.run @@ fun sw -> + let r, w = Eio_unix.pipe sw in + Fiber.both + (fun () -> + let buf = Cstruct.create 8 in + let n = Eio.Flow.single_read r buf in + Alcotest.(check string) "data" "ping" (Cstruct.to_string ~len:n buf)) + (fun () -> Eio.Flow.copy_string "ping" w) + +let test_eof () = + Switch.run @@ fun sw -> + let r, w = Eio_unix.pipe sw in + Eio.Flow.close w; + let buf = Cstruct.create 1 in + match Eio.Flow.single_read r buf with + | _ -> Alcotest.fail "read should have signaled eof" + | exception End_of_file -> () + +let tests = [ + "transfer", `Quick, test_transfer; + "read-before-write", `Quick, test_read_before_write; + "eof", `Quick, test_eof; +] diff --git a/lib_eio_windows/test/test_process.ml b/lib_eio_windows/test/test_process.ml new file mode 100644 index 000000000..a00e80d31 --- /dev/null +++ b/lib_eio_windows/test/test_process.ml @@ -0,0 +1,257 @@ +open Eio.Std + +module Process = Eio.Process + +let process env = Eio.Stdenv.process_mgr env + +let read_all flow = + let b = Buffer.create 100 in + Eio.Flow.copy flow (Eio.Flow.buffer_sink b); + Buffer.contents b + +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 + +let check_signaled msg expected = function + | `Signaled signum when signum = expected -> () + | status -> + Alcotest.failf "%s: expected signal %d, got %a" msg expected Process.pp_status status + +let std_fds = + Eio_unix.Fd.[ 0, stdin, `Blocking; 1, stdout, `Blocking; 2, stderr, `Blocking ] + +let test_exit_status env () = + Switch.run @@ fun sw -> + let mgr = process env in + let ok = Process.spawn ~sw mgr ["cmd"; "/c"; "exit"; "0"] in + check_status "exit 0" 0 (Process.await ok); + let bad = Process.spawn ~sw mgr ["cmd"; "/c"; "exit"; "5"] in + check_status "exit 5" 5 (Process.await bad) + +let test_stdout_capture env () = + let line = Process.parse_out (process env) Eio.Buf_read.line ["cmd"; "/c"; "echo"; "hello"] in + Alcotest.(check string) "stdout" "hello" line + +(* A buffer sink is not fd-backed, so a pipe and a copying fiber are needed *) +let test_stdout_flow_copy env () = + let b = Buffer.create 16 in + Process.run (process env) ~stdout:(Eio.Flow.buffer_sink b) ["cmd"; "/c"; "echo"; "hello"]; + Alcotest.(check string) "stdout" "hello" (String.trim (Buffer.contents b)) + +let test_stderr_flow_copy env () = + let b = Buffer.create 16 in + Process.run (process env) ~stderr:(Eio.Flow.buffer_sink b) ["cmd"; "/c"; "echo"; "iluvcamels"; "1>&2"]; + Alcotest.(check string) "stderr" "iluvcamels" (String.trim (Buffer.contents b)) + +let test_stdin_flow_copy env () = + let line = + Process.parse_out (process env) Eio.Buf_read.line + ~stdin:(Eio.Flow.string_source "hello\r\n") + ["findstr"; "hello"] + in + Alcotest.(check string) "echoed stdin" "hello" line + +let test_explicit_pipes env () = + Switch.run @@ fun sw -> + let from_child, to_parent = Eio_unix.pipe sw in + let from_parent, to_child = Eio_unix.pipe sw in + let child = Process.spawn ~sw (process env) ~stdin:from_parent ~stdout:to_parent ["findstr"; "hello"] in + Eio.Flow.close to_parent; + Eio.Flow.copy_string "hello\r\n" to_child; + Eio.Flow.close to_child; + let out = read_all from_child in + 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 *) +let test_stress env () = + let mgr = process env in + let spawn_one i = + Switch.run @@ fun sw -> + let from_child, to_parent = Eio_unix.pipe sw in + let token = Printf.sprintf "tok-%d" i in + let child = Process.spawn ~sw mgr ~stdout:to_parent ["cmd"; "/c"; "echo"; token] in + Eio.Flow.close to_parent; + let out = read_all from_child in + check_status token 0 (Process.await child); + Alcotest.(check string) token token (String.trim out) + in + Fiber.List.iter ~max_fibers:8 spawn_one (List.init 1_000 Fun.id) + +let test_no_handle_leak env () = + 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; + (* TODO: check localhost pinging works on Windows CI *) + let child = Process.spawn ~sw (process env) ["ping"; "-n"; "30"; "127.0.0.1"] in + Eio.Flow.close w; + let result = + Fiber.first + (fun () -> + match Eio.Flow.single_read r (Cstruct.create 1) with + | _ -> `Data + | exception End_of_file -> `Eof) + (fun () -> Eio.Time.sleep (Eio.Stdenv.clock env) 5.0; `Timeout) + in + Process.signal child Sys.sigkill; + ignore (Process.await child : Process.exit_status); + match result with + | `Eof -> () + | `Data -> Alcotest.fail "unexpected data on pipe" + | `Timeout -> Alcotest.fail "child inherited the pipe's write handle" + +(* cmd.exe needs SystemRoot *) +let test_env env () = + let systemroot = Option.value (Sys.getenv_opt "SystemRoot") ~default:"C:\\Windows" in + let line = + Process.parse_out (process env) Eio.Buf_read.line + ~env:[| "FOO=bar"; "SystemRoot=" ^ systemroot |] + ["cmd"; "/c"; "echo"; "%FOO%"] + in + Alcotest.(check string) "env var" "bar" line + +let test_cwd env () = + let cwd = Eio.Stdenv.cwd env in + let subdir = Eio.Path.(cwd / "proc-cwd-test") in + Eio.Path.mkdir subdir ~perm:0o700; + Fun.protect ~finally:(fun () -> Eio.Path.rmdir subdir) @@ fun () -> + let line = Process.parse_out (process env) Eio.Buf_read.line ~cwd:subdir ["cmd"; "/c"; "cd"] in + Alcotest.(check string) "child cwd" "proc-cwd-test" (Filename.basename line) + +let test_missing_cwd env () = + Switch.run @@ fun sw -> + let missing = Eio.Path.(Eio.Stdenv.cwd env / "proc-no-such-dir") in + match Process.spawn ~sw (process env) ~cwd:missing ["cmd"; "/c"; "exit"; "0"] with + | _ -> Alcotest.fail "Expected Not_found" + | exception Eio.Io (Eio.Fs.E (Not_found _), _) -> () + +(* Writing more than the pipe holds must not stop other fibers from running, + so the signal below lands while the write is still blocked. *) +let test_slow_stdin env () = + Switch.run @@ fun sw -> + let clock = Eio.Stdenv.clock env in + let r, w = Eio_unix.pipe sw in + let child = Process.spawn ~sw (process env) ~stdin:r ["ping"; "-n"; "10"; "127.0.0.1"] in + Eio.Flow.close r; + Fiber.both + (fun () -> + (* Fails once the child, and so its end of the pipe, is gone. *) + try Eio.Flow.copy_string (String.make (1024 * 1024) 'x') w + with Eio.Io _ -> ()) + (fun () -> + Eio.Time.sleep clock 0.1; + Process.signal child Sys.sigkill); + check_signaled "killed while the write was blocked" Sys.sigkill (Process.await child) + +let test_quoting env () = + let line = Process.parse_out (process env) Eio.Buf_read.line ["cmd"; "/c"; "echo"; "hello world"] in + Alcotest.(check string) "quoted arg" "\"hello world\"" line + +let test_explicit_executable env () = + Switch.run @@ fun sw -> + let child = + Eio_unix.Process.spawn_unix ~sw (process env) ~executable:"cmd" + ~fds:std_fds ["ignored-argv0"; "/c"; "exit"; "7"] + in + check_status "exit 7" 7 (Process.await child) + +let test_fds_above_2_rejected env () = + Switch.run @@ fun sw -> + Alcotest.check_raises "fd 3" + (Invalid_argument "spawn: only fds 0-2 are supported on Windows (got fd 3)") + (fun () -> + ignore (Eio_unix.Process.spawn_unix ~sw (process env) ~executable:"cmd.exe" + ~fds:(std_fds @ [3, Eio_unix.Fd.stdin, `Blocking]) + ["cmd"; "/c"; "exit"; "0"])) + +(* An unlisted descriptor is inherited, as on Unix. *) +let test_missing_std_fd_inherited env () = + Switch.run @@ fun sw -> + let child = + Eio_unix.Process.spawn_unix ~sw (process env) ~executable:"cmd.exe" + ~fds:[] ["cmd"; "/c"; "exit"; "3"] + in + check_status "inherited" 3 (Process.await child) + +let test_terminate env () = + Switch.run @@ fun sw -> + let child = Process.spawn ~sw (process env) ["ping"; "-n"; "30"; "127.0.0.1"] in + Process.signal child Sys.sighup; + check_signaled "terminated" Sys.sighup (Process.await child) + +let test_await_timeout env () = + Switch.run @@ fun sw -> + let child = Process.spawn ~sw (process env) ["ping"; "-n"; "30"; "127.0.0.1"] in + let clock = Eio.Stdenv.clock env in + (match Eio.Time.with_timeout_exn clock 0.5 (fun () -> Process.await child) with + | status -> Alcotest.failf "await should have timed out, got %a" Process.pp_status status + | exception Eio.Time.Timeout -> ()); + Process.signal child Sys.sigterm; + check_signaled "terminated after the timeout" Sys.sigterm (Process.await child) + +let test_cwd_escape env () = + Switch.run @@ fun sw -> + let outside = Eio.Path.(Eio.Stdenv.cwd env / "..") in + match Process.spawn ~sw (process env) ~cwd:outside ["cmd"; "/c"; "cd"] with + | _ -> Alcotest.fail "cwd outside the sandbox should be refused" + | exception Eio.Io (Eio.Fs.E (Permission_denied _), _) -> () + +(* An empty entry would end the environment block early. *) +let test_env_empty_entry env () = + Switch.run @@ fun sw -> + Alcotest.check_raises "empty entry" + (Invalid_argument "spawn: invalid environment entry \"\"") + (fun () -> + ignore (Eio_unix.Process.spawn_unix ~sw (process env) ~executable:"cmd.exe" + ~env:[| "" |] ~fds:std_fds ["cmd"; "/c"; "exit"; "0"])) + +let test_signal_after_exit env () = + Switch.run @@ fun sw -> + let child = Process.spawn ~sw (process env) ["cmd"; "/c"; "exit"; "0"] in + check_status "exit 0" 0 (Process.await child); + Process.signal child Sys.sigkill; + check_status "status unchanged" 0 (Process.await child) + +let test_stop_on_switch_release env () = + let t0 = Unix.gettimeofday () in + Switch.run (fun sw -> + let _child = Process.spawn ~sw (process env) ["ping"; "-n"; "30"; "127.0.0.1"] in + ()); + let elapsed = Unix.gettimeofday () -. t0 in + if elapsed > 20.0 then + Alcotest.failf "switch release did not stop the child (took %.1fs)" elapsed + +let test_spawn_failure env () = + Switch.run @@ fun sw -> + match Process.spawn ~sw (process env) ["nonexistent-executable-eio-test"] with + | _ -> Alcotest.fail "spawn of a nonexistent executable should fail" + | exception Eio.Io (Process.E (Process.Executable_not_found _), _) -> () + +let tests env = [ + "exit-status", `Quick, test_exit_status env; + "stdout-capture", `Quick, test_stdout_capture env; + "stdout-flow-copy", `Quick, test_stdout_flow_copy env; + "stderr-flow-copy", `Quick, test_stderr_flow_copy env; + "stdin-flow-copy", `Quick, test_stdin_flow_copy env; + "explicit-pipes", `Quick, test_explicit_pipes env; + "no-handle-leak", `Quick, test_no_handle_leak env; + "env", `Quick, test_env env; + "cwd", `Quick, test_cwd env; + "quoting", `Quick, test_quoting env; + "explicit-executable", `Quick, test_explicit_executable env; + "fds-above-2-rejected", `Quick, test_fds_above_2_rejected env; + "missing-std-fd-inherited", `Quick, test_missing_std_fd_inherited env; + "missing-cwd", `Quick, test_missing_cwd env; + "slow-stdin", `Quick, test_slow_stdin env; + "terminate", `Quick, test_terminate env; + "await-timeout", `Quick, test_await_timeout env; + "cwd-escape", `Quick, test_cwd_escape env; + "env-empty-entry", `Quick, test_env_empty_entry env; + "signal-after-exit", `Quick, test_signal_after_exit env; + "stop-on-switch-release", `Quick, test_stop_on_switch_release env; + "spawn-failure", `Quick, test_spawn_failure env; + "stress", `Slow, test_stress env; +]