Forge
csharpa2ee7322
1using System.ComponentModel;
2using System.Diagnostics;
3using System.Runtime.InteropServices;
4using System.Text;
5using CascadeIDE.Contracts;
6using Microsoft.Win32.SafeHandles;
7
8namespace CascadeIDE.Features.Terminal.DataAcquisition;
9
10[IoBoundary]
11internal sealed class WindowsConPtyIntegratedShellSession(ShellLaunchConfiguration launch) : IIntegratedShellSession
12{
13 private readonly object _syncRoot = new();
14
15 private WindowsPseudoConsoleSafeHandle? _pseudoConsole;
16 private SafeFileHandle? _inputWriteHandle;
17 private SafeFileHandle? _outputReadHandle;
18 private FileStream? _inputStream;
19 private FileStream? _outputStream;
20 private Process? _process;
21 private CancellationTokenSource? _lifetimeCancellation;
22 private bool _isDisposed;
23 private (int cols, int rows)? _lastResize;
24
25 public event Action<byte[]>? DataReceived;
26
27 public event Action<int>? Exited;
28
29 public void Start()
30 {
31 if (!OperatingSystem.IsWindows())
32 throw new PlatformNotSupportedException("ConPTY is only available on Windows.");
33
34 if (!NativeMethods.CreatePipePair(out var inputReadHandle, out var inputWriteHandle))
35 throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to create ConPTY input pipe.");
36
37 if (!NativeMethods.CreatePipePair(out var outputReadHandle, out var outputWriteHandle))
38 {
39 inputReadHandle.Dispose();
40 inputWriteHandle.Dispose();
41 throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to create ConPTY output pipe.");
42 }
43
44 try
45 {
46 NativeMethods.ClearHandleInheritance(inputWriteHandle);
47 NativeMethods.ClearHandleInheritance(outputReadHandle);
48
49 var result = NativeMethods.CreatePseudoConsole(
50 new Coord(80, 25),
51 inputReadHandle.DangerousGetHandle(),
52 outputWriteHandle.DangerousGetHandle(),
53 0,
54 out var pseudoConsoleHandle);
55
56 if (result != 0)
57 Marshal.ThrowExceptionForHR(result);
58
59 _pseudoConsole = new WindowsPseudoConsoleSafeHandle(pseudoConsoleHandle);
60 _inputWriteHandle = inputWriteHandle;
61 _outputReadHandle = outputReadHandle;
62
63 inputReadHandle.Dispose();
64 outputWriteHandle.Dispose();
65
66 StartProcessAttachedToPseudoConsole(_pseudoConsole);
67
68 _inputStream = new FileStream(_inputWriteHandle, FileAccess.Write, 4096, isAsync: false);
69 _outputStream = new FileStream(_outputReadHandle, FileAccess.Read, 4096, isAsync: false);
70 _lifetimeCancellation = new CancellationTokenSource();
71
72 _ = Task.Run(() => PumpOutput(_outputStream, _lifetimeCancellation.Token));
73 _ = WaitForExitAsync(_lifetimeCancellation.Token);
74 }
75 catch
76 {
77 inputReadHandle.Dispose();
78 outputWriteHandle.Dispose();
79 Dispose();
80 throw;
81 }
82 }
83
84 public void Send(byte[] input)
85 {
86 try
87 {
88 if (_inputStream?.CanWrite != true)
89 return;
90
91 var normalizedInput = IntegratedShellLaunch.NormalizeStandardInput(input, launch.DisplayName);
92 _inputStream.Write(normalizedInput, 0, normalizedInput.Length);
93 _inputStream.Flush();
94 }
95 catch (IOException)
96 {
97 }
98 catch (ObjectDisposedException)
99 {
100 }
101 }
102
103 public void Resize(int cols, int rows)
104 {
105 if (!TryBeginResize(cols, rows, out var pseudoConsoleHandle, out var normalized))
106 return;
107
108 try
109 {
110 var result = NativeMethods.ResizePseudoConsole(
111 pseudoConsoleHandle,
112 new Coord((short)normalized.cols, (short)normalized.rows));
113 if (result != 0)
114 Marshal.ThrowExceptionForHR(result);
115 }
116 catch (ExternalException)
117 {
118 }
119 catch (ObjectDisposedException)
120 {
121 }
122 }
123
124 public void Dispose()
125 {
126 lock (_syncRoot)
127 _isDisposed = true;
128
129 _lifetimeCancellation?.Cancel();
130 _lifetimeCancellation?.Dispose();
131 _lifetimeCancellation = null;
132
133 try
134 {
135 _inputStream?.Dispose();
136 }
137 catch (IOException)
138 {
139 }
140
141 try
142 {
143 _outputStream?.Dispose();
144 }
145 catch (IOException)
146 {
147 }
148
149 if (_process is { HasExited: false })
150 _process.Kill(entireProcessTree: true);
151
152 _process?.Dispose();
153 _process = null;
154
155 _pseudoConsole?.Dispose();
156 _pseudoConsole = null;
157
158 _inputWriteHandle?.Dispose();
159 _inputWriteHandle = null;
160
161 _outputReadHandle?.Dispose();
162 _outputReadHandle = null;
163 }
164
165 private bool TryBeginResize(int cols, int rows, out IntPtr pseudoConsoleHandle, out (int cols, int rows) normalized)
166 {
167 pseudoConsoleHandle = IntPtr.Zero;
168 normalized = (Math.Max(cols, 1), Math.Max(rows, 1));
169
170 if (!OperatingSystem.IsWindows())
171 return false;
172
173 lock (_syncRoot)
174 {
175 if (_isDisposed
176 || _process is { HasExited: true }
177 || _pseudoConsole is not { IsInvalid: false, IsClosed: false })
178 {
179 return false;
180 }
181
182 if (_lastResize == normalized)
183 return false;
184
185 _lastResize = normalized;
186 pseudoConsoleHandle = _pseudoConsole.DangerousGetHandle();
187 return true;
188 }
189 }
190
191 private void StartProcessAttachedToPseudoConsole(WindowsPseudoConsoleSafeHandle pseudoConsole)
192 {
193 IntPtr attributeList = IntPtr.Zero;
194 IntPtr commandLine = IntPtr.Zero;
195 IntPtr environmentBlock = IntPtr.Zero;
196 SafeFileHandle? processHandle = null;
197 SafeFileHandle? threadHandle = null;
198
199 try
200 {
201 var attributeListSize = IntPtr.Zero;
202 NativeMethods.InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref attributeListSize);
203
204 attributeList = Marshal.AllocHGlobal(attributeListSize);
205 if (!NativeMethods.InitializeProcThreadAttributeList(attributeList, 1, 0, ref attributeListSize))
206 throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to initialize process attribute list.");
207
208 if (!NativeMethods.UpdateProcThreadAttribute(
209 attributeList,
210 0,
211 NativeMethods.PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
212 pseudoConsole.DangerousGetHandle(),
213 (IntPtr)IntPtr.Size,
214 IntPtr.Zero,
215 IntPtr.Zero))
216 {
217 throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to attach pseudo console attribute.");
218 }
219
220 var startupInfo = new StartupInfoEx
221 {
222 StartupInfo = { cb = Marshal.SizeOf<StartupInfo>() },
223 lpAttributeList = attributeList,
224 };
225
226 commandLine = Marshal.StringToHGlobalUni(BuildCommandLine(launch));
227
228 var environmentVariables = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
229 foreach (System.Collections.DictionaryEntry entry in Environment.GetEnvironmentVariables())
230 environmentVariables[(string)entry.Key] = (string?)entry.Value ?? string.Empty;
231
232 environmentVariables["TERM"] = "xterm-256color";
233 environmentVariables["COLORTERM"] = "truecolor";
234 IntegratedShellLaunch.ApplyUtf8ConsoleEnvironment(environmentVariables);
235 environmentBlock = Marshal.StringToHGlobalUni(BuildEnvironmentBlock(environmentVariables));
236
237 if (!NativeMethods.CreateProcess(
238 lpApplicationName: null,
239 lpCommandLine: commandLine,
240 lpProcessAttributes: IntPtr.Zero,
241 lpThreadAttributes: IntPtr.Zero,
242 bInheritHandles: false,
243 dwCreationFlags: NativeMethods.EXTENDED_STARTUPINFO_PRESENT | NativeMethods.CREATE_UNICODE_ENVIRONMENT,
244 lpEnvironment: environmentBlock,
245 lpCurrentDirectory: launch.WorkingDirectory,
246 lpStartupInfo: ref startupInfo,
247 lpProcessInformation: out var processInformation))
248 {
249 throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to start process attached to ConPTY.");
250 }
251
252 processHandle = new SafeFileHandle(processInformation.hProcess, ownsHandle: true);
253 threadHandle = new SafeFileHandle(processInformation.hThread, ownsHandle: true);
254
255 _process = Process.GetProcessById((int)processInformation.dwProcessId);
256 }
257 finally
258 {
259 threadHandle?.Dispose();
260 processHandle?.Dispose();
261
262 if (attributeList != IntPtr.Zero)
263 {
264 NativeMethods.DeleteProcThreadAttributeList(attributeList);
265 Marshal.FreeHGlobal(attributeList);
266 }
267
268 if (commandLine != IntPtr.Zero)
269 Marshal.FreeHGlobal(commandLine);
270
271 if (environmentBlock != IntPtr.Zero)
272 Marshal.FreeHGlobal(environmentBlock);
273 }
274 }
275
276 private void PumpOutput(Stream stream, CancellationToken cancellationToken)
277 {
278 var buffer = new byte[4096];
279 try
280 {
281 while (!cancellationToken.IsCancellationRequested)
282 {
283 var bytesRead = stream.Read(buffer, 0, buffer.Length);
284 if (bytesRead == 0)
285 break;
286
287 DataReceived?.Invoke(buffer.AsSpan(0, bytesRead).ToArray());
288 }
289 }
290 catch (OperationCanceledException)
291 {
292 }
293 catch (ObjectDisposedException)
294 {
295 }
296 catch (IOException)
297 {
298 }
299 }
300
301 private async Task WaitForExitAsync(CancellationToken cancellationToken)
302 {
303 if (_process is null)
304 return;
305
306 try
307 {
308 await _process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
309 Exited?.Invoke(_process.ExitCode);
310 }
311 catch (OperationCanceledException)
312 {
313 }
314 catch (InvalidOperationException)
315 {
316 }
317 }
318
319 private static string BuildCommandLine(ShellLaunchConfiguration launch) =>
320 string.Join(" ", new[] { QuoteArgument(launch.FileName) }.Concat(launch.Arguments.Select(QuoteArgument)));
321
322 private static string BuildEnvironmentBlock(IReadOnlyDictionary<string, string> environmentVariables)
323 {
324 var builder = new StringBuilder();
325 foreach (var entry in environmentVariables.OrderBy(static pair => pair.Key, StringComparer.OrdinalIgnoreCase))
326 builder.Append(entry.Key).Append('=').Append(entry.Value).Append('\0');
327
328 builder.Append('\0');
329 return builder.ToString();
330 }
331
332 private static string QuoteArgument(string argument)
333 {
334 if (string.IsNullOrEmpty(argument))
335 return "\"\"";
336
337 if (!argument.Any(static ch => char.IsWhiteSpace(ch) || ch is '"'))
338 return argument;
339
340 var builder = new StringBuilder();
341 builder.Append('"');
342
343 var backslashCount = 0;
344 foreach (var ch in argument)
345 {
346 if (ch == '\\')
347 {
348 backslashCount++;
349 continue;
350 }
351
352 if (ch == '"')
353 {
354 builder.Append('\\', (backslashCount * 2) + 1);
355 builder.Append('"');
356 backslashCount = 0;
357 continue;
358 }
359
360 if (backslashCount > 0)
361 {
362 builder.Append('\\', backslashCount);
363 backslashCount = 0;
364 }
365
366 builder.Append(ch);
367 }
368
369 if (backslashCount > 0)
370 builder.Append('\\', backslashCount * 2);
371
372 builder.Append('"');
373 return builder.ToString();
374 }
375
376 [StructLayout(LayoutKind.Sequential)]
377 private struct Coord(short x, short y)
378 {
379 public short X = x;
380 public short Y = y;
381 }
382
383 [StructLayout(LayoutKind.Sequential)]
384 private struct SecurityAttributes
385 {
386 public int nLength;
387 public IntPtr lpSecurityDescriptor;
388
389 [MarshalAs(UnmanagedType.Bool)]
390 public bool bInheritHandle;
391 }
392
393 [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
394 private struct StartupInfo
395 {
396 public int cb;
397 public IntPtr lpReserved;
398 public IntPtr lpDesktop;
399 public IntPtr lpTitle;
400 public int dwX;
401 public int dwY;
402 public int dwXSize;
403 public int dwYSize;
404 public int dwXCountChars;
405 public int dwYCountChars;
406 public int dwFillAttribute;
407 public int dwFlags;
408 public short wShowWindow;
409 public short cbReserved2;
410 public IntPtr lpReserved2;
411 public IntPtr hStdInput;
412 public IntPtr hStdOutput;
413 public IntPtr hStdError;
414 }
415
416 [StructLayout(LayoutKind.Sequential)]
417 private struct StartupInfoEx
418 {
419 public StartupInfo StartupInfo;
420 public IntPtr lpAttributeList;
421 }
422
423 [StructLayout(LayoutKind.Sequential)]
424 private struct ProcessInformation
425 {
426 public IntPtr hProcess;
427 public IntPtr hThread;
428 public uint dwProcessId;
429 public uint dwThreadId;
430 }
431
432 private sealed class WindowsPseudoConsoleSafeHandle : SafeHandleZeroOrMinusOneIsInvalid
433 {
434 public WindowsPseudoConsoleSafeHandle()
435 : base(ownsHandle: true)
436 {
437 }
438
439 public WindowsPseudoConsoleSafeHandle(IntPtr preexistingHandle, bool ownsHandle = true)
440 : base(ownsHandle)
441 {
442 SetHandle(preexistingHandle);
443 }
444
445 protected override bool ReleaseHandle()
446 {
447 NativeMethods.ClosePseudoConsole(handle);
448 return true;
449 }
450 }
451
452 private static class NativeMethods
453 {
454 internal const int HANDLE_FLAG_INHERIT = 0x00000001;
455 internal const int EXTENDED_STARTUPINFO_PRESENT = 0x00080000;
456 internal const int CREATE_UNICODE_ENVIRONMENT = 0x00000400;
457 internal static readonly IntPtr PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = (IntPtr)0x00020016;
458
459 [DllImport("kernel32.dll", SetLastError = true)]
460 private static extern bool CreatePipe(
461 out SafeFileHandle hReadPipe,
462 out SafeFileHandle hWritePipe,
463 ref SecurityAttributes lpPipeAttributes,
464 int nSize);
465
466 [DllImport("kernel32.dll", SetLastError = true)]
467 private static extern bool SetHandleInformation(SafeHandle hObject, int dwMask, int dwFlags);
468
469 [DllImport("kernel32.dll", SetLastError = true)]
470 internal static extern int CreatePseudoConsole(
471 Coord size,
472 IntPtr hInput,
473 IntPtr hOutput,
474 uint dwFlags,
475 out IntPtr phPC);
476
477 [DllImport("kernel32.dll", SetLastError = true)]
478 internal static extern int ResizePseudoConsole(IntPtr hPC, Coord size);
479
480 [DllImport("kernel32.dll")]
481 internal static extern void ClosePseudoConsole(IntPtr hPC);
482
483 [DllImport("kernel32.dll", SetLastError = true)]
484 internal static extern bool InitializeProcThreadAttributeList(
485 IntPtr lpAttributeList,
486 int dwAttributeCount,
487 int dwFlags,
488 ref IntPtr lpSize);
489
490 [DllImport("kernel32.dll", SetLastError = true)]
491 internal static extern bool UpdateProcThreadAttribute(
492 IntPtr lpAttributeList,
493 uint dwFlags,
494 IntPtr attribute,
495 IntPtr lpValue,
496 IntPtr cbSize,
497 IntPtr lpPreviousValue,
498 IntPtr lpReturnSize);
499
500 [DllImport("kernel32.dll")]
501 internal static extern void DeleteProcThreadAttributeList(IntPtr lpAttributeList);
502
503 [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
504 internal static extern bool CreateProcess(
505 string? lpApplicationName,
506 IntPtr lpCommandLine,
507 IntPtr lpProcessAttributes,
508 IntPtr lpThreadAttributes,
509 [MarshalAs(UnmanagedType.Bool)] bool bInheritHandles,
510 int dwCreationFlags,
511 IntPtr lpEnvironment,
512 string? lpCurrentDirectory,
513 ref StartupInfoEx lpStartupInfo,
514 out ProcessInformation lpProcessInformation);
515
516 internal static bool CreatePipePair(out SafeFileHandle readPipe, out SafeFileHandle writePipe)
517 {
518 var attributes = new SecurityAttributes
519 {
520 nLength = Marshal.SizeOf<SecurityAttributes>(),
521 bInheritHandle = true,
522 };
523
524 return CreatePipe(out readPipe, out writePipe, ref attributes, 0);
525 }
526
527 internal static void ClearHandleInheritance(SafeHandle handle)
528 {
529 if (!SetHandleInformation(handle, HANDLE_FLAG_INHERIT, 0))
530 throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to clear handle inheritance.");
531 }
532 }
533}
534
View only · write via MCP/CIDE