/// Native PTY on Windows via ConPTY (`CreatePseudoConsole`). /// /// Mirrors the POSIX [NativePty] lifecycle (see `native_pty.dart`): /// spawn a child attached to a pseudo-console, surface its output as /// a byte stream, accept writes / resizes / kills, reap on close. /// /// The Win32 sequence: /// /// 1. Two anonymous pipes — one ConPTY reads child input from, one /// it writes rendered VT output to. /// 2. `CreatePseudoConsole(size, inRead, outWrite)` → `HPCON`. The /// conpty-side ends (`inRead` / `outWrite`) must stay open for /// the pseudo console's whole lifetime: on current Windows 11 /// the conpty host runs IN-PROCESS and uses these very handles /// (the old "conhost dups them, close immediately" advice from /// the EchoCon sample era silently breaks output — the freed /// handle slot gets recycled and conhost writes land wherever /// it now points, observed empirically as output appearing on /// the parent's console). /// 3. `CreateProcessW` with `EXTENDED_STARTUPINFO_PRESENT`, the /// `HPCON` attached via `PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE`, /// and `STARTF_USESTDHANDLES` with NULL std handles — without /// that a console parent's std handles leak into the child and /// its stdout bypasses the conpty entirely (also empirical; the /// conpty handshake still fires, which makes it look attached). /// 4. A reader isolate blocks on `ReadFile(outRead)`; a waiter /// isolate blocks on `WaitForSingleObject(hProcess, INFINITE)`. /// On child exit the waiter reports back and the main isolate /// calls `ClosePseudoConsole` and closes the conpty-side pipe /// ends — that breaks the output pipe, so the reader drains /// whatever is still buffered, sees `ERROR_BROKEN_PIPE`, and /// sends EOF. /// /// Requires Windows 10 1809+ (first ConPTY release). All symbols /// live in kernel32.dll. Errors carry `GetLastError()` in /// [PtyException.errno] (a Win32 error code, not a POSIX errno). library; import 'dart:async'; import 'dart:ffi' as ffi; import 'dart:io' show File, Platform; import 'dart:isolate'; import 'dart:typed_data'; import 'package:ffi/ffi.dart'; import 'errors.dart'; import 'pty_log.dart'; import 'pty_session.dart'; import 'pty_size.dart'; // coverage:ignore-start // // Everything from here to the `resolveExecutable` helper below is the // Windows-only ConPTY FFI path: Win32 structs, kernel32 bindings, and the // `WindowsPty` session that calls CreatePseudoConsole / CreateProcessW / // WaitForSingleObject. The kernel32 symbols all resolve through one // `DynamicLibrary.open('kernel32.dll')` handle (below), which has no Linux // equivalent — so any method that touches a binding cannot even be ENTERED // on the ubuntu-latest runner that produces the coverage report, and the // syscall sites are genuinely uncoverable off-Windows. // // Honest caveat: this span is excluded at FILE granularity, but it is not // 100% syscall. A few fragments are pure input->output and COULD be unit- // tested on Linux if extracted into free helpers (the way the three pure // helpers below already were): the _Coord / _StartupInfoExW struct packing // and write()'s empty/length guard. They stay entangled here only because // they share a method with a kernel32 binding; T-431 tracks pulling them // (and the POSIX marshalling in native_pty.dart) into testable helpers and // shrinking this ignore span to the raw syscalls. // // The pure, platform-agnostic spawn helpers (resolveExecutable / quoteArg / // composeEnvironmentBlock) sit AFTER the ignore-end below and ARE covered on // every platform by test/pty/windows_pty_args_test.dart. The FFI path's // BEHAVIOUR — not its line coverage — is what the Windows runner validates: // windows.yml spawns real ConPTY children (start/write/resize/kill/errors) // but collects no coverage, so there is intentionally no line-coverage // metric for this span anywhere. End-to-end leak/handle correctness is // proven by the Windows VM soak (tools/windows-verify/). // -- structs ---------------------------------------------------------------- /// Win32 `COORD` — passed BY VALUE to Create/ResizePseudoConsole. final class _Coord extends ffi.Struct { @ffi.Int16() external int x; @ffi.Int16() external int y; } /// Win32 `STARTUPINFOEXW`. Field names follow the Win32 struct so the /// layout is checkable against ``; Dart FFI derives /// offsets from declaration order + C alignment rules, which match MSVC /// here (cb is followed by 4 bytes of padding before the first pointer). final class _StartupInfoExW extends ffi.Struct { @ffi.Uint32() external int cb; external ffi.Pointer lpReserved; external ffi.Pointer lpDesktop; external ffi.Pointer lpTitle; @ffi.Uint32() external int dwX; @ffi.Uint32() external int dwY; @ffi.Uint32() external int dwXSize; @ffi.Uint32() external int dwYSize; @ffi.Uint32() external int dwXCountChars; @ffi.Uint32() external int dwYCountChars; @ffi.Uint32() external int dwFillAttribute; @ffi.Uint32() external int dwFlags; @ffi.Uint16() external int wShowWindow; @ffi.Uint16() external int cbReserved2; external ffi.Pointer lpReserved2; external ffi.Pointer hStdInput; external ffi.Pointer hStdOutput; external ffi.Pointer hStdError; external ffi.Pointer lpAttributeList; } /// Win32 `PROCESS_INFORMATION`. final class _ProcessInformation extends ffi.Struct { external ffi.Pointer hProcess; external ffi.Pointer hThread; @ffi.Uint32() external int dwProcessId; @ffi.Uint32() external int dwThreadId; } // -- FFI bindings ----------------------------------------------------------- final ffi.DynamicLibrary _k32 = ffi.DynamicLibrary.open('kernel32.dll'); typedef _Handle = ffi.Pointer; final _createPipe = _k32 .lookupFunction< ffi.Int32 Function(ffi.Pointer<_Handle>, ffi.Pointer<_Handle>, ffi.Pointer, ffi.Uint32), int Function(ffi.Pointer<_Handle>, ffi.Pointer<_Handle>, ffi.Pointer, int) >('CreatePipe'); final _createPseudoConsole = _k32 .lookupFunction< ffi.Int32 Function(_Coord, _Handle, _Handle, ffi.Uint32, ffi.Pointer<_Handle>), int Function(_Coord, _Handle, _Handle, int, ffi.Pointer<_Handle>) >('CreatePseudoConsole'); final _resizePseudoConsole = _k32.lookupFunction('ResizePseudoConsole'); final _closePseudoConsole = _k32.lookupFunction('ClosePseudoConsole'); final _initAttrList = _k32 .lookupFunction< ffi.Int32 Function(ffi.Pointer, ffi.Uint32, ffi.Uint32, ffi.Pointer), int Function(ffi.Pointer, int, int, ffi.Pointer) >('InitializeProcThreadAttributeList'); final _updateAttr = _k32 .lookupFunction< ffi.Int32 Function(ffi.Pointer, ffi.Uint32, ffi.IntPtr, ffi.Pointer, ffi.IntPtr, ffi.Pointer, ffi.Pointer), int Function(ffi.Pointer, int, int, ffi.Pointer, int, ffi.Pointer, ffi.Pointer) >('UpdateProcThreadAttribute'); final _deleteAttrList = _k32.lookupFunction), void Function(ffi.Pointer)>('DeleteProcThreadAttributeList'); final _createProcessW = _k32 .lookupFunction< ffi.Int32 Function( ffi.Pointer, ffi.Pointer, ffi.Pointer, ffi.Pointer, ffi.Int32, ffi.Uint32, ffi.Pointer, ffi.Pointer, ffi.Pointer<_StartupInfoExW>, ffi.Pointer<_ProcessInformation>, ), int Function( ffi.Pointer, ffi.Pointer, ffi.Pointer, ffi.Pointer, int, int, ffi.Pointer, ffi.Pointer, ffi.Pointer<_StartupInfoExW>, ffi.Pointer<_ProcessInformation>, ) >('CreateProcessW'); final _writeFile = _k32 .lookupFunction< ffi.Int32 Function(_Handle, ffi.Pointer, ffi.Uint32, ffi.Pointer, ffi.Pointer), int Function(_Handle, ffi.Pointer, int, ffi.Pointer, ffi.Pointer) >('WriteFile'); final _closeHandle = _k32.lookupFunction('CloseHandle'); final _getLastError = _k32.lookupFunction('GetLastError'); final _terminateProcess = _k32.lookupFunction('TerminateProcess'); final _getExitCodeProcess = _k32.lookupFunction), int Function(_Handle, ffi.Pointer)>( 'GetExitCodeProcess', ); // Constants — duplicated from / . const int _kExtendedStartupinfoPresent = 0x00080000; const int _kCreateUnicodeEnvironment = 0x00000400; const int _kProcThreadAttributePseudoconsole = 0x00020016; const int _kInfinite = 0xffffffff; const int _kErrorBrokenPipe = 109; const int _kStartfUseStdHandles = 0x00000100; // -- WindowsPty ------------------------------------------------------------- /// A pseudo-terminal backed by ConPTY via Dart FFI. class WindowsPty implements PtySession { WindowsPty._(this._hpc, this._hProcess, this._hThread, this._inWrite, this._outRead, this._conptyInRead, this._conptyOutWrite, this.pid); /// HPCON — owned until [close] / child exit. ffi.Pointer _hpc; final ffi.Pointer _hProcess; final ffi.Pointer _hThread; /// Our end of the child-stdin pipe (we write, ConPTY reads). final ffi.Pointer _inWrite; /// Our end of the child-stdout pipe (ConPTY writes, we read). final ffi.Pointer _outRead; /// The conpty-side pipe ends. Open for the HPCON's lifetime — the /// in-process conpty host uses them directly; released together /// with it in [_closeConsole]. Closing our `_conptyOutWrite` copy /// is also what finally breaks the pipe for the reader's EOF. final ffi.Pointer _conptyInRead; final ffi.Pointer _conptyOutWrite; @override final int pid; final _out = StreamController.broadcast(); bool _dead = false; bool _handlesReleased = false; Future? _readerReady; Isolate? _readerIsolate; ReceivePort? _readerPort; Completer? _readerExited; ReceivePort? _waiterPort; /// Breadcrumb file path + verbosity threaded into the reader/waiter isolates /// (T-434). Plain values so they survive `Isolate.spawn`. String? _crumbPath; bool _verbose = false; @override Stream get output => _out.stream; @override bool get isClosed => _dead; /// Spawn a new ConPTY running [executable] with [arguments]. /// /// [environment] must be the complete environment — it becomes the /// child's whole environment block. Merge `Platform.environment` /// before calling. static WindowsPty start({ required String executable, List arguments = const [], required int columns, required int rows, String? workingDirectory, Map environment = const {}, PtyLog log = PtyLog.none, }) { log.crumb('conpty: start exe=$executable'); executable = resolveExecutable(executable, environment); // ---- Pipes + pseudo console --------------------------------------- final ha = calloc<_Handle>(); final hb = calloc<_Handle>(); if (_createPipe(ha, hb, ffi.nullptr, 0) == 0) { final err = _getLastError(); calloc.free(ha); calloc.free(hb); throw PtyException('CreatePipe', 'stdin pipe creation failed', errno: err); } final inRead = ha.value; final inWrite = hb.value; if (_createPipe(ha, hb, ffi.nullptr, 0) == 0) { final err = _getLastError(); _closeHandle(inRead); _closeHandle(inWrite); calloc.free(ha); calloc.free(hb); throw PtyException('CreatePipe', 'stdout pipe creation failed', errno: err); } final outRead = ha.value; final outWrite = hb.value; calloc.free(ha); calloc.free(hb); final size = calloc<_Coord>() ..ref.x = clampPtyDimension(columns) ..ref.y = clampPtyDimension(rows); final hpcOut = calloc<_Handle>(); log.crumb('conpty: CreatePseudoConsole enter'); final hr = _createPseudoConsole(size.ref, inRead, outWrite, 0, hpcOut); log.crumb('conpty: CreatePseudoConsole -> hr=$hr'); calloc.free(size); if (hr != 0) { _closeHandle(inRead); _closeHandle(inWrite); _closeHandle(outRead); _closeHandle(outWrite); calloc.free(hpcOut); throw PtyException('CreatePseudoConsole', 'HRESULT 0x${(hr & 0xffffffff).toRadixString(16)}'); } final hpc = hpcOut.value; calloc.free(hpcOut); // inRead / outWrite deliberately stay open — the in-process conpty // uses them for its whole lifetime (see the library docstring). // _closeConsole() releases them together with the HPCON. // ---- Attribute list (attaches the HPCON to the child) ------------- final sizeOut = calloc(); _initAttrList(ffi.nullptr, 1, 0, sizeOut); // sizing call; "fails" with ERROR_INSUFFICIENT_BUFFER by design final attrBytes = sizeOut.value; final attrList = calloc(attrBytes).cast(); void freeAttrs() { calloc.free(attrList); calloc.free(sizeOut); } void bail(String op, String message) { final err = _getLastError(); freeAttrs(); _closePseudoConsole(hpc); _closeHandle(inRead); _closeHandle(outWrite); _closeHandle(inWrite); _closeHandle(outRead); throw PtyException(op, message, errno: err); } if (_initAttrList(attrList, 1, 0, sizeOut) == 0) { bail('InitializeProcThreadAttributeList', 'attribute list init failed'); } // The HPCON itself is lpValue — the attribute machinery stores the // pointer, it does NOT copy through it. Passing a pointer-to-slot // here "succeeds" but hands the child a garbage console and ConPTY // silently produces no output. (Matches the EchoCon sample.) if (_updateAttr(attrList, 0, _kProcThreadAttributePseudoconsole, hpc, ffi.sizeOf<_Handle>(), ffi.nullptr, ffi.nullptr) == 0) { _deleteAttrList(attrList); bail('UpdateProcThreadAttribute', 'attaching HPCON failed'); } // ---- Marshal command line + environment + cwd ---------------------- // App name stays null so CreateProcessW does its own first-token // parse (which also gives .bat/.cmd their cmd.exe host); the // executable is pre-resolved to an absolute path above so no PATH // ambiguity is left at this point. final cmdLine = [executable, ...arguments].map(quoteArg).join(' ').toNativeUtf16(allocator: malloc); final envBlock = composeEnvironmentBlock(environment).toNativeUtf16(allocator: malloc); final cwdN = workingDirectory == null ? ffi.nullptr : workingDirectory.toNativeUtf16(allocator: malloc); // STARTF_USESTDHANDLES with NULL std handles (calloc zeroes them): // the console subsystem then assigns conpty-backed handles at // client connect instead of leaking the parent's (docstring §3). final si = calloc<_StartupInfoExW>() ..ref.cb = ffi.sizeOf<_StartupInfoExW>() ..ref.dwFlags = _kStartfUseStdHandles ..ref.lpAttributeList = attrList; final pi = calloc<_ProcessInformation>(); log.crumb('conpty: CreateProcessW enter'); final ok = _createProcessW( ffi.nullptr, cmdLine, ffi.nullptr, ffi.nullptr, 0, _kExtendedStartupinfoPresent | _kCreateUnicodeEnvironment, envBlock.cast(), cwdN.cast(), si, pi, ); final spawnErr = ok == 0 ? _getLastError() : 0; log.crumb('conpty: CreateProcessW -> ok=$ok err=$spawnErr'); _deleteAttrList(attrList); freeAttrs(); malloc.free(cmdLine); malloc.free(envBlock); if (cwdN != ffi.nullptr) malloc.free(cwdN.cast()); calloc.free(si); if (ok == 0) { calloc.free(pi); _closePseudoConsole(hpc); _closeHandle(inRead); _closeHandle(outWrite); _closeHandle(inWrite); _closeHandle(outRead); throw PtyException('CreateProcessW', 'spawn of $executable failed', errno: spawnErr); } final hProcess = pi.ref.hProcess; final hThread = pi.ref.hThread; final childPid = pi.ref.dwProcessId; calloc.free(pi); final pty = WindowsPty._(hpc, hProcess, hThread, inWrite, outRead, inRead, outWrite, childPid) .._crumbPath = log.crumbPath .._verbose = log.verbose; log.crumb('conpty: spawned pid=$childPid'); pty._spawnReader(); pty._spawnWaiter(); return pty; } // -- I/O -------------------------------------------------------------- void _spawnReader() { _readerReady = _spawnReaderAsync(); } Future _spawnReaderAsync() async { final rp = ReceivePort(); _readerPort = rp; _readerExited = Completer(); rp.listen((msg) { if (msg == null) { if (!_out.isClosed) _out.close(); rp.close(); _readerPort = null; if (!_readerExited!.isCompleted) _readerExited!.complete(); _reap(); } else { if (!_out.isClosed) _out.add(msg as Uint8List); } }); try { _readerIsolate = await Isolate.spawn(_readLoop, (rp.sendPort, _outRead.address, _crumbPath, _verbose)); } catch (e) { _dead = true; if (!_out.isClosed) _out.addError(PtyException('reader-spawn', '$e')); rp.close(); _readerPort = null; if (!_readerExited!.isCompleted) _readerExited!.complete(); } } /// Isolate entry — blocking ReadFile until the ConPTY side closes. static void _readLoop((SendPort, int, String?, bool) msg) { final (port, handleAddr, crumbPath, verbose) = msg; // This isolate is the prime suspect for the freeze: ReadFile blocks // forever if the ConPTY host never closes the pipe, and Isolate.kill // can't interrupt the FFI (dart-lang/sdk#46680). It opens its OWN append // handle so its last "ReadFile enter" crumb survives even a frozen main // isolate — the breadcrumb that NAMES the wedge after a power-cycle (T-434). final crumbs = IsolateCrumbFile(crumbPath, 'conpty.reader'); crumbs.crumb('reader started handle=$handleAddr'); final handle = ffi.Pointer.fromAddress(handleAddr); final k32 = ffi.DynamicLibrary.open('kernel32.dll'); final readFile = k32 .lookupFunction< ffi.Int32 Function(_Handle, ffi.Pointer, ffi.Uint32, ffi.Pointer, ffi.Pointer), int Function(_Handle, ffi.Pointer, int, ffi.Pointer, ffi.Pointer) >('ReadFile'); final buf = malloc(65536); final nRead = calloc(); var reason = 'eof'; try { while (true) { // Blocks until data, broken pipe (ConPTY closed), or invalid // handle (close() already released it). if (verbose) crumbs.crumb('ReadFile enter'); final ok = readFile(handle, buf, 65536, nRead, ffi.nullptr); if (verbose) crumbs.crumb('ReadFile -> ok=$ok n=${nRead.value}'); if (ok == 0) { reason = 'broken-pipe/invalid'; break; } final n = nRead.value; if (n == 0) { reason = 'n=0'; break; } port.send(Uint8List.fromList(buf.asTypedList(n))); } } finally { calloc.free(nRead); malloc.free(buf); } crumbs.crumb('reader exiting ($reason)'); crumbs.close(); port.send(null); } /// Watches for child exit so the pseudo console can be torn down — /// without ClosePseudoConsole the output pipe never breaks and the /// reader would block forever on an exited child. void _spawnWaiter() { final wp = ReceivePort(); _waiterPort = wp; wp.listen((_) { wp.close(); _waiterPort = null; _closeConsole(); }); Isolate.spawn(_waitLoop, (wp.sendPort, _hProcess.address, _crumbPath)).catchError((Object e) { // Fall back to close()-driven teardown; the child just won't be // auto-reaped on self-exit. wp.close(); _waiterPort = null; return Isolate.current; // satisfies the Future type; unused }); } static void _waitLoop((SendPort, int, String?) msg) { final (port, handleAddr, crumbPath) = msg; final crumbs = IsolateCrumbFile(crumbPath, 'conpty.waiter'); crumbs.crumb('waiter started; WaitForSingleObject(INFINITE) enter'); final k32 = ffi.DynamicLibrary.open('kernel32.dll'); final wait = k32.lookupFunction('WaitForSingleObject'); final r = wait(ffi.Pointer.fromAddress(handleAddr), _kInfinite); crumbs.crumb('WaitForSingleObject -> $r (child exited)'); crumbs.close(); port.send(null); } @override int write(List bytes) { if (_dead || bytes.isEmpty) return 0; final buf = malloc(bytes.length); final nWritten = calloc(); try { for (var i = 0; i < bytes.length; i++) { buf[i] = bytes[i]; } var written = 0; while (written < bytes.length) { final ok = _writeFile(_inWrite, buf + written, bytes.length - written, nWritten, ffi.nullptr); if (ok == 0) { final err = _getLastError(); if (err == _kErrorBrokenPipe) _dead = true; throw PtyException('WriteFile', 'write to ConPTY failed', errno: err); } if (nWritten.value == 0) break; written += nWritten.value; } return written; } finally { malloc.free(buf); calloc.free(nWritten); } } @override void resize({required int cols, required int rows}) { if (_dead || _hpc == ffi.nullptr) return; final size = calloc<_Coord>() ..ref.x = clampPtyDimension(cols) ..ref.y = clampPtyDimension(rows); _resizePseudoConsole(_hpc, size.ref); calloc.free(size); } /// Windows has no signals — any [signal] terminates the child. @override bool kill([int? signal]) { if (_dead || _handlesReleased) return false; return _terminateProcess(_hProcess, 1) != 0; } /// Close the HPCON and the conpty-side pipe ends, once. With every /// write end of the output pipe gone the reader drains what's left /// and EOFs. void _closeConsole() { final hpc = _hpc; if (hpc == ffi.nullptr) return; _hpc = ffi.nullptr; _closePseudoConsole(hpc); _closeHandle(_conptyInRead); _closeHandle(_conptyOutWrite); } void _reap() { if (_dead) return; _dead = true; _closeConsole(); _releaseHandles(); } void _releaseHandles() { if (_handlesReleased) return; _handlesReleased = true; final code = calloc(); _getExitCodeProcess(_hProcess, code); calloc.free(code); _closeHandle(_inWrite); _closeHandle(_outRead); _closeHandle(_hThread); _closeHandle(_hProcess); } /// Kill the child and release resources. /// /// Order matters, mirroring the POSIX close(): terminate the child, /// break the output pipe (ClosePseudoConsole), wait for the reader /// to EOF so nothing touches the handles after we close them. @override Future close() async { if (_dead) return; _dead = true; await _readerReady; _terminateProcess(_hProcess, 1); _closeConsole(); if (_readerExited != null) { await _readerExited!.future.timeout(const Duration(milliseconds: 500), onTimeout: () {}); } _readerIsolate?.kill(priority: Isolate.immediate); _readerIsolate = null; _readerPort?.close(); _readerPort = null; _waiterPort?.close(); _waiterPort = null; _releaseHandles(); if (!_out.isClosed) await _out.close(); } // coverage:ignore-end // -- spawn helpers ------------------------------------------------------ /// Resolve a bare command name against the environment's PATH + /// PATHEXT (mirrors what the POSIX side does with `:`-split PATH — /// visible/debuggable resolution instead of CreateProcess magic). /// /// [exists] overrides the on-disk probe so the resolution logic is /// unit-testable off-Windows; production passes the default. Public for /// that reason — not part of the backend's external contract. static String resolveExecutable(String executable, Map environment, {bool Function(String path)? exists}) { final fileExists = exists ?? ((String path) => File(path).existsSync()); final pathext = (environment['PATHEXT'] ?? Platform.environment['PATHEXT'] ?? '.COM;.EXE;.BAT;.CMD').split(';').where((e) => e.isNotEmpty).toList(); final hasKnownExt = pathext.any((e) => executable.toLowerCase().endsWith(e.toLowerCase())); Iterable candidates(String base) sync* { if (hasKnownExt) { yield base; } else { yield base; for (final ext in pathext) { yield '$base$ext'; } } } if (executable.contains('\\') || executable.contains('/')) { for (final c in candidates(executable)) { if (fileExists(c)) return c; } return executable; } final path = environment['PATH'] ?? Platform.environment['PATH'] ?? ''; for (final dir in path.split(';')) { if (dir.isEmpty) continue; for (final c in candidates('$dir\\$executable')) { if (fileExists(c)) return c; } } return executable; } /// Quote one argument per MSVCRT command-line parsing rules. Public so the /// quoting rules can be unit-tested off-Windows; not an external contract. static String quoteArg(String arg) { if (arg.isNotEmpty && !arg.contains(RegExp(r'[ \t"\n\v]'))) return arg; final b = StringBuffer('"'); var backslashes = 0; for (final ch in arg.runes) { final c = String.fromCharCode(ch); if (c == r'\') { backslashes++; continue; } if (c == '"') { b.write(r'\' * (backslashes * 2 + 1)); b.write('"'); backslashes = 0; continue; } if (backslashes > 0) { b.write(r'\' * backslashes); backslashes = 0; } b.write(c); } b.write(r'\' * (backslashes * 2)); b.write('"'); return b.toString(); } /// Compose the body of a CREATE_UNICODE_ENVIRONMENT block: `K=V\0...\0` /// with one trailing NUL, entries sorted case-insensitively by key per /// CreateProcess docs. The caller nativizes via `toNativeUtf16`, whose own /// terminator completes the required double-NUL ending (which also keeps an /// empty environment block valid). Public for unit testing. static String composeEnvironmentBlock(Map environment) { final entries = environment.entries.toList()..sort((a, b) => a.key.toUpperCase().compareTo(b.key.toUpperCase())); // NUL via fromCharCode — an inline NUL escape in a string literal // is invisible in review and trips up text tooling. final nul = String.fromCharCode(0); final joined = entries.map((e) => '${e.key}=${e.value}$nul').join(); return '$joined$nul'; } }