ordinary-process.spec.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. import koffi from 'koffi'
  2. import { describe, expect, it, vi } from 'vitest'
  3. import {
  4. closeHandleChecked,
  5. isJobEmpty,
  6. pollProcessExit,
  7. probeCurrentTokenJobSupport,
  8. spawnCurrentTokenJobProcess,
  9. terminateJob,
  10. Win32Error,
  11. } from '../src/index.ts'
  12. import {
  13. CREATE_SUSPENDED,
  14. CREATE_UNICODE_ENVIRONMENT,
  15. JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET,
  16. JOBOBJECT_BASIC_ACCOUNTING_SIZE,
  17. JobObjectBasicAccountingInformation,
  18. WAIT_TIMEOUT,
  19. } from '../src/abi.ts'
  20. import { PROCESS_INFORMATION, STARTUPINFOW } from '../src/ffi.ts'
  21. import type {
  22. CurrentTokenProcessSpawnOptions,
  23. CurrentTokenProcessBindings,
  24. NativePtr,
  25. } from '../src/index.ts'
  26. function options(
  27. overrides: Partial<CurrentTokenProcessSpawnOptions> = {},
  28. ): CurrentTokenProcessSpawnOptions {
  29. return {
  30. command: 'probe.exe',
  31. applicationName: 'C:\\resolved\\probe.exe',
  32. args: [],
  33. cwd: 'C:\\work',
  34. env: {},
  35. stdio: { stdin: 4, stdout: 5, stderr: 6 },
  36. ...overrides,
  37. }
  38. }
  39. function api(overrides: Partial<CurrentTokenProcessBindings> = {}): CurrentTokenProcessBindings {
  40. return {
  41. createJobObjectW: vi.fn(() => 50n),
  42. setInformationJobObject: vi.fn(() => 1),
  43. queryInformationJobObject: vi.fn((_job: NativePtr, _cls: number, information: Buffer) => {
  44. information.writeUInt32LE(0, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET)
  45. return 1
  46. }),
  47. getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)),
  48. uvGetOsfhandle: vi.fn((fileDescriptor: number) => BigInt(100 + fileDescriptor)),
  49. setHandleInformation: vi.fn(() => 1),
  50. createProcessW: vi.fn((_app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => {
  51. koffi.encode(info, PROCESS_INFORMATION, {
  52. hProcess: 60n,
  53. hThread: 61n,
  54. dwProcessId: 1234,
  55. dwThreadId: 5678,
  56. })
  57. return 1
  58. }),
  59. assignProcessToJobObject: vi.fn(() => 1),
  60. resumeThread: vi.fn(() => 0),
  61. terminateProcess: vi.fn(() => 1),
  62. terminateJobObject: vi.fn(() => 1),
  63. waitForSingleObject: vi.fn(() => 0),
  64. getExitCodeProcess: vi.fn((_process, slot) => {
  65. koffi.encode(slot, 'uint32', 42)
  66. return 1
  67. }),
  68. closeHandle: vi.fn(() => 1),
  69. getLastError: vi.fn(() => 5),
  70. formatMessageW: vi.fn(() => 0),
  71. ...overrides,
  72. } as unknown as CurrentTokenProcessBindings
  73. }
  74. describe('ordinary Job process operations', () => {
  75. it('creates suspended, assigns the Job, and resumes before returning', () => {
  76. const events: string[] = []
  77. const createProcessW = vi.fn((
  78. _app: unknown,
  79. _line: unknown,
  80. _pa: unknown,
  81. _ta: unknown,
  82. _inherit: unknown,
  83. _flags: unknown,
  84. _env: unknown,
  85. _cwd: unknown,
  86. _startup: unknown,
  87. info: NativePtr,
  88. ) => {
  89. events.push('create')
  90. koffi.encode(info, PROCESS_INFORMATION, { hProcess: 60n, hThread: 61n, dwProcessId: 1234, dwThreadId: 5678 })
  91. return 1
  92. })
  93. const bindings = api({
  94. createProcessW,
  95. assignProcessToJobObject: vi.fn(() => { events.push('assign'); return 1 }),
  96. resumeThread: vi.fn(() => { events.push('resume'); return 0 }),
  97. closeHandle: vi.fn((handle: NativePtr) => { events.push(`close:${handle}`); return 1 }),
  98. })
  99. expect(spawnCurrentTokenJobProcess(bindings, options({
  100. args: ['literal $VALUE', 'a b'],
  101. env: {
  102. ZED: 'last',
  103. '=C:': 'C:\\work',
  104. alpha: 'first',
  105. ALPHA: 'same-folded-key',
  106. _A: 'underscore',
  107. },
  108. }))).toEqual({ pid: 1234, process: 60n, job: 50n })
  109. const environment = createProcessW.mock.calls[0]?.[6] as Buffer
  110. expect(createProcessW).toHaveBeenCalledWith(
  111. 'C:\\resolved\\probe.exe',
  112. 'probe.exe "literal $VALUE" "a b"',
  113. null,
  114. null,
  115. 1,
  116. CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT,
  117. environment,
  118. 'C:\\work',
  119. expect.anything(),
  120. expect.anything(),
  121. )
  122. expect(environment.toString('utf16le')).toBe(
  123. '=C:=C:\\work\0alpha=first\0ALPHA=same-folded-key\0ZED=last\0_A=underscore\0\0',
  124. )
  125. expect(events.indexOf('create')).toBeLessThan(events.indexOf('assign'))
  126. expect(events.indexOf('assign')).toBeLessThan(events.indexOf('resume'))
  127. expect(events).toContain('close:61')
  128. })
  129. it('reports CreateProcessW failure without replaying another creator', () => {
  130. const bindings = api({ createProcessW: vi.fn(() => 0) })
  131. let caught: unknown
  132. try {
  133. spawnCurrentTokenJobProcess(bindings, options({ command: 'missing.exe' }))
  134. } catch (error) {
  135. caught = error
  136. }
  137. expect(caught).toMatchObject({ api: 'CreateProcessW', win32Code: 5 })
  138. })
  139. it('resolves the target carrier descriptors and restores their handle flags', () => {
  140. let startup: Record<string, unknown> | undefined
  141. const setHandleInformation = vi.fn(() => 1)
  142. const uvGetOsfhandle = vi.fn(
  143. (fileDescriptor: number) => BigInt(100 + fileDescriptor) as NativePtr,
  144. )
  145. const bindings = api({
  146. setHandleInformation,
  147. uvGetOsfhandle,
  148. createProcessW: vi.fn((_app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, infoPtr, processInfo) => {
  149. startup = koffi.decode(infoPtr, STARTUPINFOW) as Record<string, unknown>
  150. koffi.encode(processInfo, PROCESS_INFORMATION, {
  151. hProcess: 60n,
  152. hThread: 61n,
  153. dwProcessId: 1234,
  154. dwThreadId: 5678,
  155. })
  156. return 1
  157. }),
  158. })
  159. expect(spawnCurrentTokenJobProcess(bindings, options())).toEqual({ pid: 1234, process: 60n, job: 50n })
  160. expect(startup).toMatchObject({ hStdInput: 104n, hStdOutput: 105n, hStdError: 106n })
  161. expect(uvGetOsfhandle).toHaveBeenNthCalledWith(1, 4)
  162. expect(uvGetOsfhandle).toHaveBeenNthCalledWith(2, 5)
  163. expect(uvGetOsfhandle).toHaveBeenNthCalledWith(3, 6)
  164. expect(setHandleInformation.mock.calls).toEqual([
  165. [104n, 1, 1], [105n, 1, 1], [106n, 1, 1],
  166. [104n, 1, 0], [105n, 1, 0], [106n, 1, 0],
  167. ])
  168. })
  169. it('polls direct exit and Job emptiness without blocking', () => {
  170. const queryInformationJobObject = vi.fn((_job: NativePtr, _cls: number, information: Buffer) => {
  171. information.writeUInt32LE(1, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET)
  172. return 1
  173. })
  174. const running = api({
  175. waitForSingleObject: vi.fn(() => WAIT_TIMEOUT),
  176. queryInformationJobObject,
  177. })
  178. expect(pollProcessExit(running, 60n as NativePtr)).toBeUndefined()
  179. expect(isJobEmpty(running, 50n as NativePtr)).toBe(false)
  180. expect(queryInformationJobObject).toHaveBeenCalledWith(
  181. 50n,
  182. JobObjectBasicAccountingInformation,
  183. expect.objectContaining({ length: JOBOBJECT_BASIC_ACCOUNTING_SIZE }),
  184. JOBOBJECT_BASIC_ACCOUNTING_SIZE,
  185. null,
  186. )
  187. const exited = api()
  188. expect(pollProcessExit(exited, 60n as NativePtr)).toBe(42)
  189. expect(isJobEmpty(exited, 50n as NativePtr)).toBe(true)
  190. })
  191. it('reports wait and exit-code query failures', () => {
  192. const processWait = api({ waitForSingleObject: vi.fn(() => 0xFFFFFFFF) })
  193. expect(() => pollProcessExit(processWait, 60n as NativePtr)).toThrow(Win32Error)
  194. const exitCode = api({ getExitCodeProcess: vi.fn(() => 0) })
  195. expect(() => pollProcessExit(exitCode, 60n as NativePtr)).toThrow(Win32Error)
  196. const jobQuery = api({ queryInformationJobObject: vi.fn(() => 0) })
  197. expect(() => isJobEmpty(jobQuery, 50n as NativePtr)).toThrow(Win32Error)
  198. })
  199. it('checks Job termination and caller-owned handle closure', () => {
  200. const terminateJobObject = vi.fn(() => 1)
  201. const bindings = api({ terminateJobObject })
  202. expect(() => { terminateJob(bindings, 50n as NativePtr, 1) }).not.toThrow()
  203. expect(() => { closeHandleChecked(bindings, 50n as NativePtr, 'test Job') }).not.toThrow()
  204. expect(terminateJobObject).toHaveBeenCalledWith(50n, 1)
  205. const failing = api({ terminateJobObject: vi.fn(() => 0) })
  206. expect(() => { terminateJob(failing, 50n as NativePtr, 1) }).toThrow(Win32Error)
  207. const closeFailure = api({ closeHandle: vi.fn(() => 0) })
  208. expect(() => { closeHandleChecked(closeFailure, 50n as NativePtr, 'test Job') }).toThrow(Win32Error)
  209. })
  210. it('probes an unnamed Job and closes its handle', () => {
  211. const closeHandle = vi.fn(() => 1)
  212. const bindings = api({ closeHandle })
  213. expect(() => { probeCurrentTokenJobSupport(bindings) }).not.toThrow()
  214. expect(closeHandle).toHaveBeenCalledExactlyOnceWith(50n)
  215. })
  216. it('rejects invalid carrier handles before target creation', () => {
  217. const expectFailure = (invalid: NativePtr | null): void => {
  218. const closeHandle = vi.fn(() => 1)
  219. const createProcessW = vi.fn(() => 1)
  220. expect(() => spawnCurrentTokenJobProcess(api({
  221. closeHandle,
  222. createProcessW,
  223. uvGetOsfhandle: vi.fn(() => invalid),
  224. }), options())).toThrow('uv_get_osfhandle returned an invalid handle for target stdin fd 4')
  225. expect(closeHandle).toHaveBeenCalledWith(50n)
  226. expect(createProcessW).not.toHaveBeenCalled()
  227. }
  228. for (const invalid of [null, 0n, 0xffff_ffff_ffff_ffffn, 0xffff_ffff_ffff_fffen]) {
  229. expectFailure(invalid as NativePtr | null)
  230. }
  231. })
  232. })