process-failure-paths.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. /**
  2. * Failure-path unit tests with minimal stub binding tables: the spawn
  3. * helpers must close every handle they created before throwing, and
  4. * every generic process failure remains owned by the shared package.
  5. * Pure stubs — no real Win32 calls, so these run on every platform.
  6. */
  7. import { describe, expect, it, vi } from 'vitest'
  8. import koffi from 'koffi'
  9. import {
  10. Win32Error,
  11. drainPipe,
  12. spawnInheritedJobProcess,
  13. spawnPipedProcess,
  14. waitForProcessExit,
  15. } from '../src/index.ts'
  16. import type { NativePtr, Win32ProcessBindings } from '../src/index.ts'
  17. import * as abi from '../src/abi.ts'
  18. import { PROCESS_INFORMATION } from '../src/ffi.ts'
  19. const PVOID = koffi.pointer('void')
  20. /** The stub the CreateProcessAsUserW failure branch needs: pipes "succeed", the spawn fails with Win32 5. */
  21. function pipeFailureApi(): { api: Win32ProcessBindings; closed: bigint[]; closeHandle: ReturnType<typeof vi.fn> } {
  22. const closed: bigint[] = []
  23. let next = 1n
  24. const closeHandle = vi.fn((handle: NativePtr) => {
  25. closed.push(handle)
  26. return 1
  27. })
  28. const api = {
  29. createPipe: vi.fn((readSlot: NativePtr, writeSlot: NativePtr) => {
  30. koffi.encode(readSlot, PVOID, next++)
  31. koffi.encode(writeSlot, PVOID, next++)
  32. return 1
  33. }),
  34. setHandleInformation: vi.fn(() => 1),
  35. createProcessAsUserW: vi.fn(() => 0),
  36. getLastError: vi.fn(() => 5), // ERROR_ACCESS_DENIED: the failure the branch reports
  37. closeHandle,
  38. formatMessageW: vi.fn(() => 0),
  39. } as unknown as Win32ProcessBindings
  40. return { api, closed, closeHandle }
  41. }
  42. describe('spawn failure paths close their handles', () => {
  43. // A dummy token value; the stubbed spawn never reads it.
  44. const token = 1n as NativePtr
  45. it('closes all six pipe handles before throwing when CreateProcessAsUserW fails', () => {
  46. const { api, closed, closeHandle } = pipeFailureApi()
  47. let caught: unknown
  48. try {
  49. spawnPipedProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })
  50. } catch (error) {
  51. caught = error
  52. }
  53. expect(caught).toBeInstanceOf(Win32Error)
  54. expect((caught as Win32Error).api).toBe('CreateProcessAsUserW')
  55. expect((caught as Win32Error).win32Code).toBe(5)
  56. expect(closeHandle).toHaveBeenCalledTimes(6)
  57. expect(closed).toEqual([1n, 2n, 3n, 4n, 5n, 6n])
  58. })
  59. })
  60. /** The stub the pipe-happy path needs: CreatePipe fills both out slots with fresh handles. */
  61. function pipeOkApi(overrides: Partial<Win32ProcessBindings> = {}): {
  62. api: Win32ProcessBindings
  63. closed: bigint[]
  64. closeHandle: ReturnType<typeof vi.fn>
  65. } {
  66. const closed: bigint[] = []
  67. let next = 1n
  68. const closeHandle = vi.fn((handle: NativePtr) => {
  69. closed.push(handle)
  70. return 1
  71. })
  72. const api = {
  73. createPipe: vi.fn((readSlot: NativePtr, writeSlot: NativePtr) => {
  74. koffi.encode(readSlot, PVOID, next++)
  75. koffi.encode(writeSlot, PVOID, next++)
  76. return 1
  77. }),
  78. setHandleInformation: vi.fn(() => 1),
  79. createProcessAsUserW: vi.fn((
  80. _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown,
  81. _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr,
  82. ) => {
  83. koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 })
  84. return 1
  85. }),
  86. getLastError: vi.fn(() => 5),
  87. closeHandle,
  88. formatMessageW: vi.fn(() => 0),
  89. ...overrides,
  90. } as unknown as Win32ProcessBindings
  91. return { api, closed, closeHandle }
  92. }
  93. describe('spawn pipe failures close their handles', () => {
  94. const token = 1n as NativePtr
  95. it('reports a CreatePipe failure', () => {
  96. const api = {
  97. createPipe: vi.fn(() => 0),
  98. getLastError: vi.fn(() => 5),
  99. formatMessageW: vi.fn(() => 0),
  100. } as unknown as Win32ProcessBindings
  101. let caught: unknown
  102. try {
  103. spawnPipedProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })
  104. } catch (error) {
  105. caught = error
  106. }
  107. expect(caught).toBeInstanceOf(Win32Error)
  108. expect((caught as Win32Error).api).toBe('CreatePipe')
  109. })
  110. it('reports a NULL pipe handle after CreatePipe succeeds', () => {
  111. const api = {
  112. createPipe: vi.fn(() => 1),
  113. getLastError: vi.fn(() => 5),
  114. formatMessageW: vi.fn(() => 0),
  115. } as unknown as Win32ProcessBindings
  116. let caught: unknown
  117. try {
  118. spawnPipedProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })
  119. } catch (error) {
  120. caught = error
  121. }
  122. expect(caught).toBeInstanceOf(Win32Error)
  123. expect((caught as Win32Error).api).toBe('CreatePipe')
  124. })
  125. it('reports a SetHandleInformation failure', () => {
  126. const { api } = pipeOkApi({ setHandleInformation: vi.fn(() => 0) })
  127. let caught: unknown
  128. try {
  129. spawnPipedProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })
  130. } catch (error) {
  131. caught = error
  132. }
  133. expect(caught).toBeInstanceOf(Win32Error)
  134. expect((caught as Win32Error).api).toBe('SetHandleInformation')
  135. })
  136. it('rejects NULL process/thread handles after a successful spawn', () => {
  137. const { api } = pipeOkApi({
  138. createProcessAsUserW: vi.fn((
  139. _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown,
  140. _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr,
  141. ) => {
  142. koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: null, hThread: null, dwProcessId: 1234, dwThreadId: 5678 })
  143. return 1
  144. }),
  145. })
  146. expect(() => spawnPipedProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }))
  147. .toThrow(/null process\/thread handles/u)
  148. })
  149. })
  150. describe('spawnInheritedJobProcess failure paths', () => {
  151. const token = 1n as NativePtr
  152. /** The stub the inherited-happy path needs; overrides flip one call per test. */
  153. function inheritedApi(overrides: Partial<Win32ProcessBindings> = {}): {
  154. api: Win32ProcessBindings
  155. closed: bigint[]
  156. closeHandle: ReturnType<typeof vi.fn>
  157. } {
  158. const closed: bigint[] = []
  159. let std = 50n
  160. const closeHandle = vi.fn((handle: NativePtr) => {
  161. closed.push(handle)
  162. return 1
  163. })
  164. const api = {
  165. createJobObjectW: vi.fn(() => 100n),
  166. setInformationJobObject: vi.fn(() => 1),
  167. getStdHandle: vi.fn(() => std++),
  168. setHandleInformation: vi.fn(() => 1),
  169. createProcessAsUserW: vi.fn((
  170. _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown,
  171. _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr,
  172. ) => {
  173. koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 })
  174. return 1
  175. }),
  176. assignProcessToJobObject: vi.fn(() => 1),
  177. resumeThread: vi.fn(() => 0),
  178. terminateProcess: vi.fn(() => 1),
  179. getLastError: vi.fn(() => 5),
  180. closeHandle,
  181. formatMessageW: vi.fn(() => 0),
  182. ...overrides,
  183. } as unknown as Win32ProcessBindings
  184. return { api, closed, closeHandle }
  185. }
  186. it('closes the job and reports when GetStdHandle yields a NULL handle', () => {
  187. const { api, closeHandle } = inheritedApi({ getStdHandle: vi.fn(() => 0n as NativePtr) })
  188. let caught: unknown
  189. try {
  190. spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })
  191. } catch (error) {
  192. caught = error
  193. }
  194. expect(caught).toBeInstanceOf(Win32Error)
  195. expect((caught as Win32Error).api).toBe('GetStdHandle')
  196. expect(closeHandle).toHaveBeenCalledWith(100n)
  197. })
  198. it('reports a SetHandleInformation failure while enabling stdio inheritance', () => {
  199. const { api } = inheritedApi({ setHandleInformation: vi.fn(() => 0) })
  200. let caught: unknown
  201. try {
  202. spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })
  203. } catch (error) {
  204. caught = error
  205. }
  206. expect(caught).toBeInstanceOf(Win32Error)
  207. expect((caught as Win32Error).api).toBe('SetHandleInformation')
  208. })
  209. it('closes the job and reports when CreateProcessAsUserW fails', () => {
  210. const { api, closeHandle } = inheritedApi({ createProcessAsUserW: vi.fn(() => 0) })
  211. let caught: unknown
  212. try {
  213. spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })
  214. } catch (error) {
  215. caught = error
  216. }
  217. expect(caught).toBeInstanceOf(Win32Error)
  218. expect((caught as Win32Error).api).toBe('CreateProcessAsUserW')
  219. expect(closeHandle).toHaveBeenCalledWith(100n)
  220. })
  221. it('closes the job and rejects NULL process/thread handles after a successful spawn', () => {
  222. const { api, closeHandle } = inheritedApi({
  223. createProcessAsUserW: vi.fn((
  224. _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown,
  225. _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr,
  226. ) => {
  227. koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: null, hThread: null, dwProcessId: 1234, dwThreadId: 5678 })
  228. return 1
  229. }),
  230. })
  231. expect(() => spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token }))
  232. .toThrow(/null process\/thread handles/u)
  233. expect(closeHandle).toHaveBeenCalledWith(100n)
  234. })
  235. it('terminates the suspended child before closing handles when Job assignment fails', () => {
  236. const terminateProcess = vi.fn(() => 1)
  237. const { api, closeHandle, closed } = inheritedApi({
  238. assignProcessToJobObject: vi.fn(() => 0),
  239. terminateProcess,
  240. })
  241. let caught: unknown
  242. try {
  243. spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })
  244. } catch (error) {
  245. caught = error
  246. }
  247. expect(caught).toMatchObject({ api: 'AssignProcessToJobObject', win32Code: 5 })
  248. expect(terminateProcess).toHaveBeenCalledWith(200n, 1)
  249. expect(closeHandle).toHaveBeenCalledWith(201n)
  250. expect(closeHandle).toHaveBeenCalledWith(200n)
  251. expect(closeHandle).toHaveBeenCalledWith(100n)
  252. expect(closed).toEqual([201n, 200n, 100n])
  253. })
  254. it('closes the assigned child and Job when ResumeThread fails', () => {
  255. const { api, closeHandle, closed } = inheritedApi({ resumeThread: vi.fn(() => 0xFFFFFFFF) })
  256. let caught: unknown
  257. try {
  258. spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })
  259. } catch (error) {
  260. caught = error
  261. }
  262. expect(caught).toMatchObject({ api: 'ResumeThread', win32Code: 5 })
  263. expect(closeHandle).toHaveBeenCalledWith(201n)
  264. expect(closeHandle).toHaveBeenCalledWith(200n)
  265. expect(closeHandle).toHaveBeenCalledWith(100n)
  266. expect(closed).toEqual([201n, 200n, 100n])
  267. })
  268. it('closes the job and reports when SetInformationJobObject fails', () => {
  269. const { api, closeHandle } = inheritedApi({ setInformationJobObject: vi.fn(() => 0) })
  270. let caught: unknown
  271. try {
  272. spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })
  273. } catch (error) {
  274. caught = error
  275. }
  276. expect(caught).toBeInstanceOf(Win32Error)
  277. expect((caught as Win32Error).api).toBe('SetInformationJobObject')
  278. expect(closeHandle).toHaveBeenCalledWith(100n)
  279. })
  280. it('closes the job and reports a NULL job object', () => {
  281. const { api } = inheritedApi({ createJobObjectW: vi.fn(() => 0n as NativePtr) })
  282. let caught: unknown
  283. try {
  284. spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })
  285. } catch (error) {
  286. caught = error
  287. }
  288. expect(caught).toBeInstanceOf(Win32Error)
  289. expect((caught as Win32Error).api).toBe('CreateJobObjectW')
  290. })
  291. it('returns the pid, process handle, and kill-on-close job when every call succeeds', () => {
  292. const assignProcessToJobObject = vi.fn(() => 1)
  293. const resumeThread = vi.fn(() => 0)
  294. const { api, closeHandle } = inheritedApi({ assignProcessToJobObject, resumeThread })
  295. const spawned = spawnInheritedJobProcess(api, { command: 'probe.exe', args: [], cwd: 'C:\\', token })
  296. expect(spawned.pid).toBe(1234)
  297. expect(spawned.process).toBe(200n)
  298. expect(spawned.job).toBe(100n)
  299. // thread handle closed by the spawn; process and job handles stay with the caller.
  300. expect(closeHandle).toHaveBeenCalledWith(201n)
  301. expect(closeHandle).not.toHaveBeenCalledWith(200n)
  302. expect(closeHandle).not.toHaveBeenCalledWith(100n)
  303. expect(assignProcessToJobObject).toHaveBeenCalledWith(100n, 200n)
  304. expect(resumeThread).toHaveBeenCalledWith(201n)
  305. })
  306. })
  307. describe('drainPipe', () => {
  308. it('stops at ERROR_NO_DATA and closes the read end', () => {
  309. const closeHandle = vi.fn(() => 1)
  310. const api = {
  311. peekNamedPipe: vi.fn(() => 0),
  312. getLastError: vi.fn(() => abi.ERROR_NO_DATA),
  313. closeHandle,
  314. formatMessageW: vi.fn(() => 0),
  315. } as unknown as Win32ProcessBindings
  316. return drainPipe(api, 30n as NativePtr).then((buffer) => {
  317. expect(buffer.length).toBe(0)
  318. expect(closeHandle).toHaveBeenCalledWith(30n)
  319. })
  320. })
  321. it('reports a PeekNamedPipe failure that is not a clean EOF', () => {
  322. const closeHandle = vi.fn(() => 1)
  323. const api = {
  324. peekNamedPipe: vi.fn(() => 0),
  325. getLastError: vi.fn(() => 5),
  326. closeHandle,
  327. formatMessageW: vi.fn(() => 0),
  328. } as unknown as Win32ProcessBindings
  329. return expect(drainPipe(api, 30n as NativePtr)).rejects.toMatchObject({ api: 'PeekNamedPipe' })
  330. .then(() => { expect(closeHandle).toHaveBeenCalledWith(30n) })
  331. })
  332. it('reports a ReadFile failure after data was reported available', () => {
  333. const api = {
  334. peekNamedPipe: vi.fn((_pipe: unknown, _buffer: unknown, _size: unknown, _read: unknown, totalAvail: NativePtr) => {
  335. koffi.encode(totalAvail, 'uint32', 4)
  336. return 1
  337. }),
  338. readFile: vi.fn(() => 0),
  339. getLastError: vi.fn(() => 5),
  340. closeHandle: vi.fn(() => 1),
  341. formatMessageW: vi.fn(() => 0),
  342. } as unknown as Win32ProcessBindings
  343. return expect(drainPipe(api, 30n as NativePtr)).rejects.toMatchObject({ api: 'ReadFile' })
  344. })
  345. it('drains one chunk and stops at ERROR_BROKEN_PIPE', () => {
  346. let peeks = 0
  347. const api = {
  348. peekNamedPipe: vi.fn((_pipe: unknown, _buffer: unknown, _size: unknown, _read: unknown, totalAvail: NativePtr) => {
  349. peeks++
  350. if (peeks > 1) return 0
  351. koffi.encode(totalAvail, 'uint32', 4)
  352. return 1
  353. }),
  354. readFile: vi.fn((_file: unknown, chunk: Buffer, _count: unknown, read: NativePtr) => {
  355. chunk.write('ab', 0, 'utf8')
  356. koffi.encode(read, 'uint32', 2)
  357. return 1
  358. }),
  359. getLastError: vi.fn(() => abi.ERROR_BROKEN_PIPE),
  360. closeHandle: vi.fn(() => 1),
  361. formatMessageW: vi.fn(() => 0),
  362. } as unknown as Win32ProcessBindings
  363. return drainPipe(api, 30n as NativePtr).then((buffer) => {
  364. expect(buffer.toString('utf8')).toBe('ab')
  365. })
  366. })
  367. })
  368. describe('waitForProcessExit', () => {
  369. it('reports a WaitForSingleObject failure', () => {
  370. const closeHandle = vi.fn(() => 1)
  371. const api = {
  372. waitForSingleObject: vi.fn(() => 0xFFFFFFFF),
  373. getLastError: vi.fn(() => 5),
  374. closeHandle,
  375. formatMessageW: vi.fn(() => 0),
  376. } as unknown as Win32ProcessBindings
  377. expect(() => waitForProcessExit(api, 200n as NativePtr)).toThrow(Win32Error)
  378. expect(closeHandle).toHaveBeenCalledWith(200n)
  379. })
  380. it('reports a GetExitCodeProcess failure', () => {
  381. const closeHandle = vi.fn(() => 1)
  382. const api = {
  383. waitForSingleObject: vi.fn(() => 0),
  384. getExitCodeProcess: vi.fn(() => 0),
  385. getLastError: vi.fn(() => 5),
  386. closeHandle,
  387. formatMessageW: vi.fn(() => 0),
  388. } as unknown as Win32ProcessBindings
  389. expect(() => waitForProcessExit(api, 200n as NativePtr)).toThrow(Win32Error)
  390. expect(closeHandle).toHaveBeenCalledWith(200n)
  391. })
  392. it('returns the exit code and closes the process handle', () => {
  393. const closeHandle = vi.fn(() => 1)
  394. const api = {
  395. waitForSingleObject: vi.fn(() => 0),
  396. getExitCodeProcess: vi.fn((_process: unknown, slot: NativePtr) => {
  397. koffi.encode(slot, 'uint32', 42)
  398. return 1
  399. }),
  400. closeHandle,
  401. formatMessageW: vi.fn(() => 0),
  402. } as unknown as Win32ProcessBindings
  403. expect(waitForProcessExit(api, 200n as NativePtr)).toBe(42)
  404. expect(closeHandle).toHaveBeenCalledWith(200n)
  405. })
  406. })