import type { Context } from "@earendil-works/chord"; import type { Pty, PtyOptions } from "./environment.ts"; const EXTENDED_STARTUPINFO_PRESENT = 0x00080000; const CREATE_UNICODE_ENVIRONMENT = 0x00000400; const STARTF_USESTDHANDLES = 0x00000100; const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = 0x20016n; const INFINITE = 0xffffffff; let kernel: ReturnType | undefined; function openKernel() { return Deno.dlopen("kernel32.dll", { CreatePipe: { parameters: ["buffer", "buffer", "pointer", "u32"], result: "i32" }, CloseHandle: { parameters: ["pointer"], result: "i32" }, GetLastError: { parameters: [], result: "u32" }, CreatePseudoConsole: { parameters: ["u32", "pointer", "pointer", "u32", "buffer"], result: "i32", }, ResizePseudoConsole: { parameters: ["pointer", "u32"], result: "i32" }, // closing can wait for output to drain on Windows versions before 11 24H2 ClosePseudoConsole: { parameters: ["pointer"], result: "void", nonblocking: true }, InitializeProcThreadAttributeList: { parameters: ["buffer", "u32", "u32", "buffer"], result: "i32", }, UpdateProcThreadAttribute: { parameters: ["buffer", "u32", "usize", "pointer", "usize", "pointer", "pointer"], result: "i32", }, DeleteProcThreadAttributeList: { parameters: ["buffer"], result: "void" }, CreateProcessW: { parameters: [ "pointer", "buffer", "pointer", "pointer", "i32", "u32", "buffer", "buffer", "buffer", "buffer", ], result: "i32", }, WaitForSingleObject: { parameters: ["pointer", "u32"], result: "u32", nonblocking: true }, GetExitCodeProcess: { parameters: ["pointer", "buffer"], result: "i32" }, TerminateProcess: { parameters: ["pointer", "u32"], result: "i32" }, ReadFile: { parameters: ["pointer", "buffer", "u32", "buffer", "pointer"], result: "i32", nonblocking: true, }, WriteFile: { parameters: ["pointer", "buffer", "u32", "buffer", "pointer"], result: "i32", nonblocking: true, }, }).symbols; } function wide(text: string): Uint16Array { const buffer = new Uint16Array(text.length + 1); for (let i = 0; i < text.length; i++) buffer[i] = text.charCodeAt(i); return buffer; } function size(cols: number, rows: number): number { if (![cols, rows].every((n) => Number.isInteger(n) && n > 0 && n <= 32767)) { throw new Error("Terminal dimensions must be between 1 and 32767"); } // COORD is two signed shorts, passed by value return (rows << 16) | cols; } /** A native ConPTY session on Windows 10 1809 or newer, with UTF-8 terminal I/O. */ export async function windowsPty( argv: readonly string[], { cwd, env, cols, rows }: PtyOptions, context: Context, ): Promise { context.abortSignal?.throwIfAborted(); if (!argv.length || [...argv, cwd ?? ""].some((s) => s.includes("\0"))) { throw new Error("Expected a program and arguments without NUL characters"); } const dimensions = size(cols, rows); const c = kernel ??= openKernel(); const fail = (operation: string) => new Error(`${operation} failed (Windows error ${c.GetLastError()})`); const pointer = (handle: bigint) => Deno.UnsafePointer.create(handle); const handles = new Set(); const closeHandle = (handle: bigint) => { if (handles.delete(handle)) c.CloseHandle(pointer(handle)); }; const pipe = () => { const read = new BigUint64Array(1); const write = new BigUint64Array(1); if (!c.CreatePipe(read, write, null, 0)) throw fail("CreatePipe"); handles.add(read[0]); handles.add(write[0]); return [read[0], write[0]]; }; const console = new BigUint64Array(1); let input: bigint; let output: bigint; let process: bigint; try { const [inputRead, inputWrite] = pipe(); const [outputRead, outputWrite] = pipe(); input = inputWrite; output = outputRead; const hr = c.CreatePseudoConsole( dimensions, pointer(inputRead), pointer(outputWrite), 0, console, ); if (hr < 0) { throw new Error(`CreatePseudoConsole failed (HRESULT 0x${(hr >>> 0).toString(16)})`); } const bytes = new BigUint64Array(1); c.InitializeProcThreadAttributeList(null, 1, 0, bytes); const attributes = new Uint8Array(Number(bytes[0])); if (!c.InitializeProcThreadAttributeList(attributes, 1, 0, bytes)) { throw fail("InitializeProcThreadAttributeList"); } try { if ( !c.UpdateProcThreadAttribute( attributes, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, pointer(console[0]), 8n, null, null, ) ) { throw fail("UpdateProcThreadAttribute"); } // STARTUPINFOEXW and PROCESS_INFORMATION have the same layout on x64 and arm64 const startup = new Uint8Array(112); const view = new DataView(startup.buffer); view.setUint32(0, startup.byteLength, true); // explicit null standard handles bind to ConPTY instead of inheriting the worker's protocol pipes view.setUint32(60, STARTF_USESTDHANDLES, true); view.setBigUint64( 104, BigInt(Deno.UnsafePointer.value(Deno.UnsafePointer.of(attributes))), true, ); const info = new BigUint64Array(3); const variables = new Map(); for ( const [key, value] of Object.entries(Deno.env.toObject()).concat(Object.entries(env ?? {})) ) { if (key.includes("\0") || value.includes("\0")) { throw new Error("NUL in process environment"); } variables.set(key.toUpperCase(), [key, value]); } const block = [...variables].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0) .map(([, [key, value]]) => `${key}=${value}`).join("\0") + "\0"; // preserve quotes and their preceding backslashes through Windows command-line parsing const command = argv.map((arg) => `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1")}"` ).join(" "); if ( !c.CreateProcessW( null, wide(command), null, null, 0, EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, wide(block), cwd === undefined ? null : wide(cwd), startup, info, ) ) throw fail(`Could not start ${argv[0]}`); process = info[0]; handles.add(process); c.CloseHandle(pointer(info[1])); } finally { c.DeleteProcThreadAttributeList(attributes); } closeHandle(inputRead); closeHandle(outputWrite); } catch (error) { for (const handle of handles) closeHandle(handle); if (console[0]) await c.ClosePseudoConsole(pointer(console[0])); throw error; } let closing: Promise | undefined; let writing = Promise.resolve(); let killed = false; const close = () => { if (closing) return; closing = c.ClosePseudoConsole(pointer(console[0])) .then(() => writing.catch(() => {})) .then(() => closeHandle(input)); }; const kill = () => { if (closing) return; killed = true; // a process aborted before its console initialization can miss ConPTY's close notification c.TerminateProcess(pointer(process), 1); close(); }; context.abortSignal?.addEventListener("abort", kill, { once: true }); if (context.abortSignal?.aborted) kill(); const exited = c.WaitForSingleObject(pointer(process), INFINITE).then((status) => { const code = new Uint32Array(1); return status === 0 && c.GetExitCodeProcess(pointer(process), code) && !killed ? code[0] : null; }).finally(() => { closeHandle(process); context.abortSignal?.removeEventListener("abort", kill); close(); }); let ended = false; let cancelled = false; let reading = Promise.resolve(); const read = async () => { if (ended) return; const buffer = new Uint8Array(65536); const count = new Uint32Array(1); if (await c.ReadFile(pointer(output), buffer, buffer.length, count, null) && count[0]) { return buffer.subarray(0, count[0]); } ended = true; closeHandle(output); await closing; }; const readable = new ReadableStream({ pull(controller) { reading = read().then((chunk) => { if (cancelled) return; if (chunk) controller.enqueue(chunk); else controller.close(); }); return reading; }, async cancel() { cancelled = true; kill(); // keep draining for ClosePseudoConsole even after the consumer has gone away await reading; while (!ended) await read(); }, }, { highWaterMark: 0 }); const writable = new WritableStream({ write(chunk) { writing = (async () => { const count = new Uint32Array(1); for (let at = 0; at < chunk.length;) { if (closing) throw new Error("The terminal is closed"); const rest = chunk.subarray(at); if (!await c.WriteFile(pointer(input), rest, rest.length, count, null) || !count[0]) { throw new Error("Could not write to the terminal"); } at += count[0]; } })(); return writing; }, close: () => closeHandle(input), abort: kill, }); return { readable, writable, exited, kill, resize(cols, rows) { if (closing) return; const hr = c.ResizePseudoConsole(pointer(console[0]), size(cols, rows)); if (hr < 0) { throw new Error(`ResizePseudoConsole failed (HRESULT 0x${(hr >>> 0).toString(16)})`); } }, }; }