//go:build windows package engine import ( "fmt" "os/exec" "syscall" "unsafe" "golang.org/x/sys/windows" ) // The engine is not one process on Windows, and stopping it used to leave // recognition running. // // The installer starts it as `\Scripts\python.exe -m behavision run`. // Since Python 3.7.2 that python.exe is a REDIRECTOR: a small launcher that // spawns the base interpreter as a child and waits for it. Stop() cancelled the // context, exec terminated the launcher, and the interpreter that actually // holds the cameras and the SQLite WAL carried on with no parent, no tray icon // and nothing left that could stop it. Seen on a Windows install: "Quit // Behavision" from the tray, and the engine still running. // // The fix is the primitive Windows has for exactly this: a job object with // KILL_ON_JOB_CLOSE. Every process the engine spawns inherits membership, and // the whole tree dies when the job is terminated or when this process's last // handle to it goes away - so "quitting the app stops recognition" holds even // if the app crashes, which no amount of careful Stop() code can promise. // // The child is started SUSPENDED and resumed only after it is in the job. // Assigning after the fact leaves a window in which the launcher has already // spawned the interpreter outside it, and that window is precisely the case // this file exists to close. // prepare is applied to the command before it starts. func prepare(cmd *exec.Cmd) { if cmd.SysProcAttr == nil { cmd.SysProcAttr = &syscall.SysProcAttr{} } // CREATE_NO_WINDOW: python.exe is a console program and Behavision.exe is // not, so without this Windows opens a black console window for the // engine on a shop counter - the app looks like it has crashed into a // terminal. Output still arrives on the pipes. cmd.SysProcAttr.CreationFlags |= windows.CREATE_SUSPENDED | windows.CREATE_NO_WINDOW } // confine is applied after Start. It puts the process in a kill-on-close job, // then resumes it. It returns a function that ends the whole tree, and one // that releases the job handle once the tree has exited. // // If the job cannot be set up the process is still resumed and the plain // terminate remains: a suspended engine that never runs is strictly worse // than one that may outlive its parent. func confine(cmd *exec.Cmd) (kill func() error, release func(), err error) { pid := uint32(cmd.Process.Pid) defer resumeProcess(pid) kill = cmd.Process.Kill release = func() {} job, err := windows.CreateJobObject(nil, nil) if err != nil { return kill, release, fmt.Errorf("create job object: %w", 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 kill, release, fmt.Errorf("configure job object: %w", err) } proc, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, pid) if err != nil { windows.CloseHandle(job) return kill, release, fmt.Errorf("open engine process: %w", err) } defer windows.CloseHandle(proc) if err := windows.AssignProcessToJobObject(job, proc); err != nil { windows.CloseHandle(job) return kill, release, fmt.Errorf("assign engine to job: %w", err) } kill = func() error { return windows.TerminateJobObject(job, 1) } release = func() { windows.CloseHandle(job) } return kill, release, nil } // resumeProcess resumes every thread of a process started CREATE_SUSPENDED. // exec does not hand back the main thread handle, so it is found through the // toolhelp snapshot; a suspended new process has exactly one. func resumeProcess(pid uint32) { snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0) if err != nil { return } defer windows.CloseHandle(snap) var te windows.ThreadEntry32 te.Size = uint32(unsafe.Sizeof(te)) for err = windows.Thread32First(snap, &te); err == nil; err = windows.Thread32Next(snap, &te) { if te.OwnerProcessID != pid { continue } h, err := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, te.ThreadID) if err != nil { continue } windows.ResumeThread(h) windows.CloseHandle(h) } }