ordinary-process.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  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 { processInformationType, startupInfoType } 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, processInformationType(), {
  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.each([3, 2])('supplies fd 7 with standard handle type %s and releases its temporary inheritance', (standardType) => {
  76. let descriptorBytes: Buffer | undefined
  77. const flags = vi.fn(() => 1)
  78. const bindings = api({
  79. getFileType: vi.fn(handle => handle === 107n ? 3 : standardType),
  80. setHandleInformation: flags,
  81. createProcessW: vi.fn((_app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, startupPointer, processInfo) => {
  82. const startup = koffi.decode(startupPointer, startupInfoType()) as { cbReserved2: number; lpReserved2: NativePtr }
  83. descriptorBytes = Buffer.from(koffi.decode(startup.lpReserved2, 'uint8', startup.cbReserved2) as number[])
  84. koffi.encode(processInfo, processInformationType(), { hProcess: 60n, hThread: 61n, dwProcessId: 1234, dwThreadId: 5678 })
  85. return 1
  86. }),
  87. })
  88. expect(spawnCurrentTokenJobProcess(bindings, options({
  89. stdio: { stdin: 4, stdout: 5, stderr: 6, control: 7 },
  90. }))).toEqual({ pid: 1234, process: 60n, job: 50n })
  91. const bytes = descriptorBytes as Buffer
  92. expect(bytes.readUInt32LE(0)).toBe(8)
  93. const standardFlag = standardType === 3 ? 9 : 65
  94. expect([...bytes.subarray(4, 12)]).toEqual([standardFlag, standardFlag, standardFlag, 0, 0, 0, 0, 9])
  95. expect(bytes.readBigUInt64LE(12 + 7 * 8)).toBe(107n)
  96. expect(bytes.readBigUInt64LE(12 + 3 * 8)).toBe(0xffff_ffff_ffff_ffffn)
  97. expect(flags).toHaveBeenCalledWith(107n, 1, 1)
  98. expect(flags).toHaveBeenCalledWith(107n, 1, 0)
  99. })
  100. it('refuses a control carrier that is not a pipe before creating the target', () => {
  101. const createProcessW = vi.fn()
  102. const closeHandle = vi.fn(() => 1)
  103. const setHandleInformation = vi.fn(() => 1)
  104. const bindings = api({ getFileType: vi.fn(() => 1), createProcessW, closeHandle, setHandleInformation })
  105. expect(() => spawnCurrentTokenJobProcess(bindings, options({
  106. stdio: { stdin: 4, stdout: 5, stderr: 6, control: 7 },
  107. }))).toThrow('not a Windows pipe')
  108. expect(createProcessW).not.toHaveBeenCalled()
  109. expect(closeHandle).toHaveBeenCalledWith(50n)
  110. expect(setHandleInformation).toHaveBeenCalledWith(107n, 1, 0)
  111. })
  112. it('creates suspended, assigns the Job, and resumes before returning', () => {
  113. const events: string[] = []
  114. const createProcessW = vi.fn((
  115. _app: unknown,
  116. _line: unknown,
  117. _pa: unknown,
  118. _ta: unknown,
  119. _inherit: unknown,
  120. _flags: unknown,
  121. _env: unknown,
  122. _cwd: unknown,
  123. _startup: unknown,
  124. info: NativePtr,
  125. ) => {
  126. events.push('create')
  127. koffi.encode(info, processInformationType(), { hProcess: 60n, hThread: 61n, dwProcessId: 1234, dwThreadId: 5678 })
  128. return 1
  129. })
  130. const bindings = api({
  131. createProcessW,
  132. assignProcessToJobObject: vi.fn(() => { events.push('assign'); return 1 }),
  133. resumeThread: vi.fn(() => { events.push('resume'); return 0 }),
  134. closeHandle: vi.fn((handle: NativePtr) => { events.push(`close:${handle}`); return 1 }),
  135. })
  136. expect(spawnCurrentTokenJobProcess(bindings, options({
  137. args: ['literal $VALUE', 'a b'],
  138. env: {
  139. ZED: 'last',
  140. '=C:': 'C:\\work',
  141. alpha: 'first',
  142. ALPHA: 'same-folded-key',
  143. _A: 'underscore',
  144. },
  145. }))).toEqual({ pid: 1234, process: 60n, job: 50n })
  146. const environment = createProcessW.mock.calls[0]?.[6] as Buffer
  147. expect(createProcessW).toHaveBeenCalledWith(
  148. 'C:\\resolved\\probe.exe',
  149. 'probe.exe "literal $VALUE" "a b"',
  150. null,
  151. null,
  152. 1,
  153. CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT,
  154. environment,
  155. 'C:\\work',
  156. expect.anything(),
  157. expect.anything(),
  158. )
  159. expect(environment.toString('utf16le')).toBe(
  160. '=C:=C:\\work\0alpha=first\0ALPHA=same-folded-key\0ZED=last\0_A=underscore\0\0',
  161. )
  162. expect(events.indexOf('create')).toBeLessThan(events.indexOf('assign'))
  163. expect(events.indexOf('assign')).toBeLessThan(events.indexOf('resume'))
  164. expect(events).toContain('close:61')
  165. })
  166. it('reports CreateProcessW failure without replaying another creator', () => {
  167. const bindings = api({ createProcessW: vi.fn(() => 0) })
  168. let caught: unknown
  169. try {
  170. spawnCurrentTokenJobProcess(bindings, options({ command: 'missing.exe' }))
  171. } catch (error) {
  172. caught = error
  173. }
  174. expect(caught).toMatchObject({ api: 'CreateProcessW', win32Code: 5 })
  175. })
  176. it('resolves the target carrier descriptors and restores their handle flags', () => {
  177. let startup: Record<string, unknown> | undefined
  178. const setHandleInformation = vi.fn(() => 1)
  179. const uvGetOsfhandle = vi.fn(
  180. (fileDescriptor: number) => BigInt(100 + fileDescriptor) as NativePtr,
  181. )
  182. const bindings = api({
  183. setHandleInformation,
  184. uvGetOsfhandle,
  185. createProcessW: vi.fn((_app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, infoPtr, processInfo) => {
  186. startup = koffi.decode(infoPtr, startupInfoType()) as Record<string, unknown>
  187. koffi.encode(processInfo, processInformationType(), {
  188. hProcess: 60n,
  189. hThread: 61n,
  190. dwProcessId: 1234,
  191. dwThreadId: 5678,
  192. })
  193. return 1
  194. }),
  195. })
  196. expect(spawnCurrentTokenJobProcess(bindings, options())).toEqual({ pid: 1234, process: 60n, job: 50n })
  197. expect(startup).toMatchObject({ hStdInput: 104n, hStdOutput: 105n, hStdError: 106n })
  198. expect(uvGetOsfhandle).toHaveBeenNthCalledWith(1, 4)
  199. expect(uvGetOsfhandle).toHaveBeenNthCalledWith(2, 5)
  200. expect(uvGetOsfhandle).toHaveBeenNthCalledWith(3, 6)
  201. expect(setHandleInformation.mock.calls).toEqual([
  202. [104n, 1, 1], [105n, 1, 1], [106n, 1, 1],
  203. [104n, 1, 0], [105n, 1, 0], [106n, 1, 0],
  204. ])
  205. })
  206. it('polls direct exit and Job emptiness without blocking', () => {
  207. const queryInformationJobObject = vi.fn((_job: NativePtr, _cls: number, information: Buffer) => {
  208. information.writeUInt32LE(1, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET)
  209. return 1
  210. })
  211. const running = api({
  212. waitForSingleObject: vi.fn(() => WAIT_TIMEOUT),
  213. queryInformationJobObject,
  214. })
  215. expect(pollProcessExit(running, 60n as NativePtr)).toBeUndefined()
  216. expect(isJobEmpty(running, 50n as NativePtr)).toBe(false)
  217. expect(queryInformationJobObject).toHaveBeenCalledWith(
  218. 50n,
  219. JobObjectBasicAccountingInformation,
  220. expect.objectContaining({ length: JOBOBJECT_BASIC_ACCOUNTING_SIZE }),
  221. JOBOBJECT_BASIC_ACCOUNTING_SIZE,
  222. null,
  223. )
  224. const exited = api()
  225. expect(pollProcessExit(exited, 60n as NativePtr)).toBe(42)
  226. expect(isJobEmpty(exited, 50n as NativePtr)).toBe(true)
  227. })
  228. it('reports wait and exit-code query failures', () => {
  229. const processWait = api({ waitForSingleObject: vi.fn(() => 0xFFFFFFFF) })
  230. expect(() => pollProcessExit(processWait, 60n as NativePtr)).toThrow(Win32Error)
  231. const exitCode = api({ getExitCodeProcess: vi.fn(() => 0) })
  232. expect(() => pollProcessExit(exitCode, 60n as NativePtr)).toThrow(Win32Error)
  233. const jobQuery = api({ queryInformationJobObject: vi.fn(() => 0) })
  234. expect(() => isJobEmpty(jobQuery, 50n as NativePtr)).toThrow(Win32Error)
  235. })
  236. it('checks Job termination and caller-owned handle closure', () => {
  237. const terminateJobObject = vi.fn(() => 1)
  238. const bindings = api({ terminateJobObject })
  239. expect(() => { terminateJob(bindings, 50n as NativePtr, 1) }).not.toThrow()
  240. expect(() => { closeHandleChecked(bindings, 50n as NativePtr, 'test Job') }).not.toThrow()
  241. expect(terminateJobObject).toHaveBeenCalledWith(50n, 1)
  242. const failing = api({ terminateJobObject: vi.fn(() => 0) })
  243. expect(() => { terminateJob(failing, 50n as NativePtr, 1) }).toThrow(Win32Error)
  244. const closeFailure = api({ closeHandle: vi.fn(() => 0) })
  245. expect(() => { closeHandleChecked(closeFailure, 50n as NativePtr, 'test Job') }).toThrow(Win32Error)
  246. })
  247. it('probes an unnamed Job and closes its handle', () => {
  248. const closeHandle = vi.fn(() => 1)
  249. const bindings = api({ closeHandle })
  250. expect(() => { probeCurrentTokenJobSupport(bindings) }).not.toThrow()
  251. expect(closeHandle).toHaveBeenCalledExactlyOnceWith(50n)
  252. })
  253. it('rejects invalid carrier handles before target creation', () => {
  254. const expectFailure = (invalid: NativePtr | null): void => {
  255. const closeHandle = vi.fn(() => 1)
  256. const createProcessW = vi.fn(() => 1)
  257. expect(() => spawnCurrentTokenJobProcess(api({
  258. closeHandle,
  259. createProcessW,
  260. uvGetOsfhandle: vi.fn(() => invalid),
  261. }), options())).toThrow('uv_get_osfhandle returned an invalid handle for target stdin fd 4')
  262. expect(closeHandle).toHaveBeenCalledWith(50n)
  263. expect(createProcessW).not.toHaveBeenCalled()
  264. }
  265. for (const invalid of [null, 0n, 0xffff_ffff_ffff_ffffn, 0xffff_ffff_ffff_fffen]) {
  266. expectFailure(invalid as NativePtr | null)
  267. }
  268. })
  269. })