Files
multica/server/internal/daemon/execenv/isolation_windows.go
Bohan Jiang ed57707bb2 MUL-4923: bound daemon task preparation time (#5584)
* fix(daemon): bound pre-start task preparation

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): isolate pre-start env preparation

Run execution-environment Prepare and Reuse in a killable helper process so a timed-out attempt cannot keep writing after retry. Add FIFO lifecycle and squad Stage retry regression coverage.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): terminate Windows prepare process trees

Assign the pre-start helper to a kill-on-close Job Object before releasing its request, wait for all job members to exit on cancellation, and add a Windows runtime regression job.

Co-authored-by: multica-agent <github@multica.ai>

* ci: target Windows prepare tree regression

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-20 15:30:18 +08:00

124 lines
3.1 KiB
Go

//go:build windows
package execenv
import (
"fmt"
"os/exec"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
type preparationProcessController struct {
job windows.Handle
}
// jobObjectBasicAccountingInformation mirrors JOBOBJECT_BASIC_ACCOUNTING_INFORMATION.
// x/sys exposes QueryInformationJobObject and the information-class constant,
// but not this result struct.
type jobObjectBasicAccountingInformation struct {
TotalUserTime int64
TotalKernelTime int64
ThisPeriodTotalUserTime int64
ThisPeriodTotalKernelTime int64
TotalPageFaultCount uint32
TotalProcesses uint32
ActiveProcesses uint32
TotalTerminatedProcesses uint32
}
func newPreparationProcessController(_ *exec.Cmd) (*preparationProcessController, error) {
job, err := windows.CreateJobObject(nil, nil)
if err != nil {
return nil, err
}
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{}
info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
if _, err := windows.SetInformationJobObject(
job,
windows.JobObjectExtendedLimitInformation,
uintptr(unsafe.Pointer(&info)),
uint32(unsafe.Sizeof(info)),
); err != nil {
windows.CloseHandle(job)
return nil, fmt.Errorf("set KILL_ON_JOB_CLOSE: %w", err)
}
return &preparationProcessController{job: job}, nil
}
func (c *preparationProcessController) attach(cmd *exec.Cmd) error {
process, err := windows.OpenProcess(
windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE,
false,
uint32(cmd.Process.Pid),
)
if err != nil {
return fmt.Errorf("open helper process: %w", err)
}
defer windows.CloseHandle(process)
if err := windows.AssignProcessToJobObject(c.job, process); err != nil {
return fmt.Errorf("assign helper to job object: %w", err)
}
return nil
}
func (c *preparationProcessController) stop(_ *exec.Cmd) error {
if err := windows.TerminateJobObject(c.job, 1); err != nil {
active, queryErr := c.activeProcesses()
if queryErr == nil && active == 0 {
return nil
}
return err
}
return nil
}
// finish is called only after the direct helper has exited. Terminate any
// descendant it left behind, then wait until the Job Object reports no active
// members before the daemon may release directory ownership and retry.
func (c *preparationProcessController) finish() error {
active, err := c.activeProcesses()
if err != nil {
return err
}
if active > 0 {
if err := windows.TerminateJobObject(c.job, 1); err != nil {
return err
}
}
for {
active, err = c.activeProcesses()
if err != nil {
return err
}
if active == 0 {
return nil
}
time.Sleep(10 * time.Millisecond)
}
}
func (c *preparationProcessController) activeProcesses() (uint32, error) {
var info jobObjectBasicAccountingInformation
if err := windows.QueryInformationJobObject(
c.job,
windows.JobObjectBasicAccountingInformation,
uintptr(unsafe.Pointer(&info)),
uint32(unsafe.Sizeof(info)),
nil,
); err != nil {
return 0, fmt.Errorf("query job object members: %w", err)
}
return info.ActiveProcesses, nil
}
func (c *preparationProcessController) close() {
if c.job == 0 {
return
}
_ = windows.CloseHandle(c.job)
c.job = 0
}