Skip to content

feat(windows): add shareable job handles to JobObjectChild - #68

Open
jrandolf wants to merge 1 commit into
watchexec:mainfrom
jrandolf:job-handle
Open

jrandolf wants to merge 1 commit into
watchexec:mainfrom
jrandolf:job-handle

Conversation

@jrandolf

Copy link
Copy Markdown

JobObjectChild keeps its job's handle to itself, so only whoever holds the child can end the job, with start_kill() or by dropping it with KillOnDrop. That's a problem when someone else holds the child. We hit it in a desktop app where one thread owns a child process and waits on it, and another (quitting, or cleaning up the folder the child works in) has to end the child and everything it started. wait() takes &mut self, so the other thread can't call start_kill(). We ended up writing our own job object code just to keep the handle: start suspended, create the job, assign the process, then find and resume its thread. JobObject already does all of that.

The same goes for a caller that needs the job to outlive the child. With KillOnDrop, the job's processes are killed as soon as the child is dropped.

This adds JobObjectChild::job_handle() to both the std and Tokio frontends. It returns a JobObjectHandle:

  • It's Clone, Send and Sync. Clones share the job's one handle through an Arc.
  • terminate(exit_code) calls TerminateJobObject, as start_kill() does.
  • It implements AsHandle, so the job can be used with other job APIs without process-wrap wrapping each one or exposing windows types.
  • The job handle is closed once, when the child and the last JobObjectHandle have both been dropped, so no handle can refer to a closed job. Code that never takes a handle behaves as before, and into_inner() still leaves the job open.
let mut child = CommandWrap::with_new("watch", |command| { command.arg("ls"); })
  .wrap(JobObject)
  .spawn()?;
let job = (child.as_ref() as &dyn std::any::Any)
  .downcast_ref::<JobObjectChild>()
  .expect("JobObject is the outermost wrapper")
  .job_handle();
std::thread::spawn(move || job.terminate(1));
let status = child.wait().await?;

Internally, JobPort holds a JobObjectHandle instead of a raw HANDLE, and terminate_job() is now JobObjectHandle::terminate().

The tests are in tests/std_windows/job_handle.rs and tests/tokio_windows/job_handle.rs. Like job_object_kill_on_drop.rs, they start the test binary as a parent that starts a child of its own and prints its PID. They check that:

  • terminating through a handle on another thread, while the child is being waited on, ends both processes with the given exit code;
  • with std, a handle still terminates the job after the child has been dropped;
  • with Tokio and KillOnDrop, the descendant keeps running after the child is dropped while a handle is held, and is killed once the handle is dropped.

I ran the Windows half of CI's matrix on Windows Server 2025: stable and 1.87.0, std and tokio1, with and without default features, all features, clippy, rustdoc and fmt. I also ran the new tests 20 times in a row.

JobObjectChild kept its job's handle to itself, so only the holder of the
child could end the job: not another thread while one waits on the child, and
not anyone once the child is dropped. `job_handle()` returns a JobObjectHandle,
which can be cloned and sent across threads, terminates the job without the
child, and keeps the job open while it's held. The job is still closed once,
when the child and its last handle are gone.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant