From dbd4dd7c58603a637e6f8659c203f17892403c91 Mon Sep 17 00:00:00 2001 From: cmux-lawrence Date: Sun, 12 Jul 2026 19:32:57 -0700 Subject: [PATCH 01/11] feat: add embedder-owned OpenGL surface ABI --- include/ghostty.h | 15 ++++++++++ src/apprt/embedded.zig | 35 ++++++++++++++++++++++ src/renderer/OpenGL.zig | 65 ++++++++++++++++++++++++++++++++++------- 3 files changed, 105 insertions(+), 10 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 6a9da49c592..0ff97b0b00e 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -66,6 +66,7 @@ typedef enum { GHOSTTY_PLATFORM_INVALID, GHOSTTY_PLATFORM_MACOS, GHOSTTY_PLATFORM_IOS, + GHOSTTY_PLATFORM_OPENGL, } ghostty_platform_e; typedef enum { @@ -453,9 +454,23 @@ typedef struct { void* uiview; } ghostty_platform_ios_s; +typedef bool (*ghostty_opengl_make_current_cb)(void*); +typedef void (*ghostty_opengl_clear_current_cb)(void*); +typedef void* (*ghostty_opengl_get_proc_address_cb)(void*, const char*); +typedef void (*ghostty_opengl_swap_buffers_cb)(void*); + +typedef struct { + void* userdata; + ghostty_opengl_make_current_cb make_current; + ghostty_opengl_clear_current_cb clear_current; + ghostty_opengl_get_proc_address_cb get_proc_address; + ghostty_opengl_swap_buffers_cb swap_buffers; +} ghostty_platform_opengl_s; + typedef union { ghostty_platform_macos_s macos; ghostty_platform_ios_s ios; + ghostty_platform_opengl_s opengl; } ghostty_platform_u; typedef enum { diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 463b90bf886..83b38669f1e 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -355,6 +355,7 @@ pub const App = struct { pub const Platform = union(PlatformTag) { macos: MacOS, ios: IOS, + opengl: OpenGL, // If our build target for libghostty is not darwin then we do // not include macos support at all. @@ -368,6 +369,16 @@ pub const Platform = union(PlatformTag) { uiview: objc.Object, } else void; + /// An embedder-owned OpenGL context and presentation surface. The + /// callbacks may be invoked from Ghostty's renderer thread. + pub const OpenGL = struct { + userdata: ?*anyopaque, + make_current: *const fn (?*anyopaque) callconv(.c) bool, + clear_current: *const fn (?*anyopaque) callconv(.c) void, + get_proc_address: *const fn (?*anyopaque, [*:0]const u8) callconv(.c) ?*anyopaque, + swap_buffers: *const fn (?*anyopaque) callconv(.c) void, + }; + // The C ABI compatible version of this union. The tag is expected // to be stored elsewhere. pub const C = extern union { @@ -378,6 +389,14 @@ pub const Platform = union(PlatformTag) { ios: extern struct { uiview: ?*anyopaque, }, + + opengl: extern struct { + userdata: ?*anyopaque, + make_current: ?*const fn (?*anyopaque) callconv(.c) bool, + clear_current: ?*const fn (?*anyopaque) callconv(.c) void, + get_proc_address: ?*const fn (?*anyopaque, [*:0]const u8) callconv(.c) ?*anyopaque, + swap_buffers: ?*const fn (?*anyopaque) callconv(.c) void, + }, }; /// Initialize a Platform a tag and configuration from the C ABI. @@ -397,6 +416,21 @@ pub const Platform = union(PlatformTag) { break :ios error.UIViewMustBeSet); break :ios .{ .ios = .{ .uiview = uiview } }; } else error.UnsupportedPlatform, + + .opengl => opengl: { + const config = c_platform.opengl; + break :opengl .{ .opengl = .{ + .userdata = config.userdata, + .make_current = config.make_current orelse + return error.OpenGLMakeCurrentMustBeSet, + .clear_current = config.clear_current orelse + return error.OpenGLClearCurrentMustBeSet, + .get_proc_address = config.get_proc_address orelse + return error.OpenGLGetProcAddressMustBeSet, + .swap_buffers = config.swap_buffers orelse + return error.OpenGLSwapBuffersMustBeSet, + } }; + }, }; } }; @@ -407,6 +441,7 @@ pub const PlatformTag = enum(c_int) { macos = 1, ios = 2, + opengl = 3, }; pub const EnvVar = extern struct { diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 4b01da0c5be..b2cbab3fd71 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -4,6 +4,7 @@ pub const OpenGL = @This(); const std = @import("std"); const Allocator = std.mem.Allocator; const builtin = @import("builtin"); +const build_config = @import("../build_config.zig"); const gl = @import("opengl"); const shadertoy = @import("shadertoy.zig"); const apprt = @import("../apprt.zig"); @@ -33,6 +34,41 @@ pub const swap_chain_count = 1; const log = std.log.scoped(.opengl); +const is_embedded = build_config.artifact == .lib; +const GlProc = *const fn () callconv(.c) void; +const EmbeddedState = if (is_embedded) struct { + surface: *apprt.Surface, + platform: *const apprt.embedded.Platform.OpenGL, +} else void; +threadlocal var embedded_state: if (is_embedded) ?EmbeddedState else void = + if (is_embedded) null else {}; + +fn embeddedGetProcAddress(name: [*:0]const u8) callconv(.c) ?GlProc { + const state = embedded_state orelse return null; + const ptr = state.platform.get_proc_address( + state.platform.userdata, + name, + ) orelse return null; + return @ptrCast(@alignCast(ptr)); +} + +fn enterEmbedded(surface: *apprt.Surface) !void { + const platform = switch (surface.platform) { + .opengl => |*value| value, + else => return error.OpenGLPlatformRequired, + }; + if (!platform.make_current(platform.userdata)) + return error.OpenGLMakeCurrentFailed; + embedded_state = .{ .surface = surface, .platform = platform }; +} + +fn leaveEmbedded() void { + const state = embedded_state orelse return; + gl.glad.unload(); + state.platform.clear_current(state.platform.userdata); + embedded_state = null; +} + /// We require at least OpenGL 4.3 pub const MIN_VERSION_MAJOR = 4; pub const MIN_VERSION_MINOR = 3; @@ -56,6 +92,7 @@ pub fn init(alloc: Allocator, opts: rendererpkg.Options) error{}!OpenGL { } pub fn deinit(self: *OpenGL) void { + if (comptime is_embedded) leaveEmbedded(); self.* = undefined; } @@ -160,8 +197,6 @@ fn prepareContext(getProcAddress: anytype) !void { /// This is called early right after surface creation. pub fn surfaceInit(surface: *apprt.Surface) !void { - _ = surface; - switch (apprt.runtime) { else => @compileError("unsupported app runtime for OpenGL"), @@ -170,9 +205,9 @@ pub fn surfaceInit(surface: *apprt.Surface) !void { => try prepareContext(null), apprt.embedded => { - // TODO(mitchellh): this does nothing today to allow libghostty - // to compile for OpenGL targets but libghostty is strictly - // broken for rendering on this platforms. + try enterEmbedded(surface); + errdefer leaveEmbedded(); + try prepareContext(embeddedGetProcAddress); }, } @@ -191,12 +226,12 @@ pub fn surfaceInit(surface: *apprt.Surface) !void { pub fn finalizeSurfaceInit(self: *const OpenGL, surface: *apprt.Surface) !void { _ = self; _ = surface; + if (comptime is_embedded) leaveEmbedded(); } /// Callback called by renderer.Thread when it begins. pub fn threadEnter(self: *const OpenGL, surface: *apprt.Surface) !void { _ = self; - _ = surface; switch (apprt.runtime) { else => @compileError("unsupported app runtime for OpenGL"), @@ -209,9 +244,9 @@ pub fn threadEnter(self: *const OpenGL, surface: *apprt.Surface) !void { }, apprt.embedded => { - // TODO(mitchellh): this does nothing today to allow libghostty - // to compile for OpenGL targets but libghostty is strictly - // broken for rendering on this platforms. + try enterEmbedded(surface); + errdefer leaveEmbedded(); + try prepareContext(embeddedGetProcAddress); }, } } @@ -229,7 +264,7 @@ pub fn threadExit(self: *const OpenGL) void { }, apprt.embedded => { - // TODO: see threadEnter + leaveEmbedded(); }, } } @@ -278,6 +313,11 @@ pub fn initShaders( /// Get the current size of the runtime surface. pub fn surfaceSize(self: *const OpenGL) !struct { width: u32, height: u32 } { _ = self; + if (comptime is_embedded) { + const state = embedded_state orelse return error.OpenGLContextNotCurrent; + const size = try state.surface.getSize(); + return .{ .width = size.width, .height = size.height }; + } var viewport: [4]gl.c.GLint = undefined; gl.glad.context.GetIntegerv.?(gl.c.GL_VIEWPORT, &viewport); return .{ @@ -328,6 +368,11 @@ pub fn present(self: *OpenGL, target: Target) !void { // Keep track of this target in case we need to repeat it. self.last_target = target; + + if (comptime is_embedded) { + const state = embedded_state orelse return error.OpenGLContextNotCurrent; + state.platform.swap_buffers(state.platform.userdata); + } } /// Present the last presented target again. From df6a5975d1b87ceb4d5c99013e5ed82831d40a3b Mon Sep 17 00:00:00 2001 From: cmux-lawrence Date: Sun, 12 Jul 2026 22:05:49 -0700 Subject: [PATCH 02/11] feat: package embedded OpenGL library on Linux --- build.zig | 4 +++- src/build/GhosttyLib.zig | 4 ++-- src/build/SharedDeps.zig | 10 +++++++--- src/renderer/OpenGL.zig | 11 ++++++++--- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/build.zig b/build.zig index 2622d93dd1d..9d4a5d56d38 100644 --- a/build.zig +++ b/build.zig @@ -199,10 +199,12 @@ pub fn build(b: *std.Build) !void { lib_shared.install("ghostty-internal.dll"); lib_static.install("ghostty-internal-static.lib"); } else { - lib_shared.install("ghostty-internal.so"); + lib_shared.install("libghostty-internal.so"); lib_static.install("ghostty-internal.a"); } } + resources.install(); + if (i18n) |v| v.install(); } // macOS only artifacts. These will error if they're initialized for diff --git a/src/build/GhosttyLib.zig b/src/build/GhosttyLib.zig index b762da8bbf8..faa7216b56d 100644 --- a/src/build/GhosttyLib.zig +++ b/src/build/GhosttyLib.zig @@ -73,7 +73,7 @@ pub fn initShared( deps: *const SharedDeps, ) !GhosttyLib { const lib = b.addLibrary(.{ - .name = "ghostty", + .name = "ghostty-internal", .linkage = .dynamic, .root_module = b.createModule(.{ .root_source_file = b.path("src/main_c.zig"), @@ -269,7 +269,7 @@ fn sharedLibraryName(os_tag: std.Target.Os.Tag) []const u8 { return if (os_tag == .windows) "ghostty-internal.dll" else - "ghostty-internal.so"; + "libghostty-internal.so"; } fn staticLibraryName(os_tag: std.Target.Os.Tag) []const u8 { diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 21d5a3ac4f2..f6b4fc957f3 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -596,15 +596,19 @@ pub fn add( } } - // If we're building an exe then we have additional dependencies. - if (step.kind != .lib) { - // We always statically compile glad + // GLAD provides the OpenGL loader implementation used by the renderer. + // Embedded libraries need it just as executables do, otherwise loader + // calls compile but remain unresolved when the shared library is loaded. + if (self.config.renderer == .opengl) { step.addIncludePath(b.path("vendor/glad/include/")); step.addCSourceFile(.{ .file = b.path("vendor/glad/src/gl.c"), .flags = &.{}, }); + } + // If we're building an exe then we have additional dependencies. + if (step.kind != .lib) { // When we're targeting flatpak we ALWAYS link GTK so we // get access to glib for dbus. if (self.config.flatpak) step.linkSystemLibrary2("gtk4", dynamic_link_opts); diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index b2cbab3fd71..a6816ae8b9c 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -207,7 +207,7 @@ pub fn surfaceInit(surface: *apprt.Surface) !void { apprt.embedded => { try enterEmbedded(surface); errdefer leaveEmbedded(); - try prepareContext(embeddedGetProcAddress); + try prepareContext(&embeddedGetProcAddress); }, } @@ -246,7 +246,7 @@ pub fn threadEnter(self: *const OpenGL, surface: *apprt.Surface) !void { apprt.embedded => { try enterEmbedded(surface); errdefer leaveEmbedded(); - try prepareContext(embeddedGetProcAddress); + try prepareContext(&embeddedGetProcAddress); }, } } @@ -280,7 +280,12 @@ pub fn displayRealized(self: *const OpenGL) void { ); }, - else => @compileError("only GTK should be calling displayRealized"), + // Embedded contexts are prepared by surfaceInit and threadEnter. The + // generic renderer still references this hook, so it must compile as a + // no-op even though an embedder never calls it. + apprt.embedded => {}, + + else => @compileError("unsupported app runtime for OpenGL"), } } From 1a435a0f049e5960b5f8cdd2522a65d778007bd5 Mon Sep 17 00:00:00 2001 From: cmux-lawrence Date: Sun, 12 Jul 2026 21:54:12 -0700 Subject: [PATCH 03/11] feat: embed libghostty in Electron on Windows --- example/electron-embed-windows/.gitignore | 5 + example/electron-embed-windows/README.md | 73 + example/electron-embed-windows/binding.gyp | 34 + example/electron-embed-windows/main.js | 299 ++++ .../electron-embed-windows/package-lock.json | 411 ++++++ example/electron-embed-windows/package.json | 21 + example/electron-embed-windows/renderer.html | 61 + .../scripts/build-ghostty.ps1 | 32 + .../scripts/deploy-mesa.ps1 | 20 + .../scripts/start-software-renderer.cmd | 18 + .../scripts/stress-cycles.mjs | 136 ++ .../src/libghostty_embed_win.cc | 1245 +++++++++++++++++ pkg/opengl/glad.zig | 6 + src/Surface.zig | 9 + src/build/GhosttyLib.zig | 21 + src/renderer/OpenGL.zig | 5 +- src/renderer/Thread.zig | 11 +- src/termio/Thread.zig | 9 +- 18 files changed, 2412 insertions(+), 4 deletions(-) create mode 100644 example/electron-embed-windows/.gitignore create mode 100644 example/electron-embed-windows/README.md create mode 100644 example/electron-embed-windows/binding.gyp create mode 100644 example/electron-embed-windows/main.js create mode 100644 example/electron-embed-windows/package-lock.json create mode 100644 example/electron-embed-windows/package.json create mode 100644 example/electron-embed-windows/renderer.html create mode 100644 example/electron-embed-windows/scripts/build-ghostty.ps1 create mode 100644 example/electron-embed-windows/scripts/deploy-mesa.ps1 create mode 100644 example/electron-embed-windows/scripts/start-software-renderer.cmd create mode 100644 example/electron-embed-windows/scripts/stress-cycles.mjs create mode 100644 example/electron-embed-windows/src/libghostty_embed_win.cc diff --git a/example/electron-embed-windows/.gitignore b/example/electron-embed-windows/.gitignore new file mode 100644 index 00000000000..a0083aafcb1 --- /dev/null +++ b/example/electron-embed-windows/.gitignore @@ -0,0 +1,5 @@ +artifacts/*.json +artifacts/*.log +artifacts/computer-use/ +build/ +node_modules/ diff --git a/example/electron-embed-windows/README.md b/example/electron-embed-windows/README.md new file mode 100644 index 00000000000..694e3b95294 --- /dev/null +++ b/example/electron-embed-windows/README.md @@ -0,0 +1,73 @@ +# Electron + native libghostty on Windows + +This demo embeds a real libghostty surface in Electron 43.1.0. The Node-API +addon creates a child `HWND` under `BrowserWindow.getNativeWindowHandle()`, +owns a WGL OpenGL 4.3 context, and supplies that context through +`GHOSTTY_PLATFORM_OPENGL`. Chromium renders the panel beside it. + +There is no xterm.js dependency. `ghostty_surface_new` starts the Windows +ConPTY-backed shell, and Ghostty's OpenGL renderer presents every terminal +frame through the addon's `SwapBuffers` callback. + +## Build + +Install Zig 0.15.2, Visual Studio 2022 Build Tools with the Desktop C++ +workload, Node.js, and npm. From this directory: + +```powershell +npm install +npm run build:ghostty +npm run build +npm start +``` + +The Ghostty build now installs `ghostty-internal.lib` with +`ghostty-internal.dll`, so MSVC embedders link against stable files under +`zig-out\lib`. + +Ghostty requires desktop OpenGL 4.3. Many Windows cloud VMs expose only the +Microsoft OpenGL 1.1 RDP driver. Install a trusted OpenGL 4.3-capable GPU +driver for production. For software-rendered cloud validation, extract a +trusted Mesa Windows x64 build, then run: + +```powershell +$env:GHOSTTY_MESA_DIR = "C:\path\to\mesa" +npm run deploy:mesa +.\scripts\start-software-renderer.cmd +``` + +The deploy script renames Mesa's `opengl32.dll` and places it beside the addon +with `libgallium_wgl.dll`. The addon loads that private WGL table through +`GHOSTTY_MESA_OPENGL_PATH`; it does not replace Electron's `opengl32.dll` or +alter Chromium's graphics stack. The addon rejects contexts older than 4.3 and +reports the exact GL version instead of showing a blank pseudo-terminal. + +## Input and lifecycle + +The child window routes physical scan codes, committed UTF-16 text, focus, +selection drags, wheel scrolling, and terminal mouse reporting directly to +libghostty. Right-click first checks `ghostty_surface_mouse_captured`: captured +applications receive the button, while an uncaptured shell gets a native +Copy/Paste menu backed by Ghostty's clipboard callbacks. + +`destroy()` is idempotent. It first joins and frees the Ghostty surface while +WGL callbacks remain alive, then frees the app and config, deletes the GL +context, and destroys the child window. N-API finalization repeats the same +safe path, so closing during active output does not depend on garbage +collection order. Embedded surface creation also waits until renderer and IO +stop watchers are armed, so an immediate destroy cannot lose a startup stop +notification and deadlock teardown. + +## Stress + +`npm run stress` immediately creates and destroys 25 surfaces to exercise the +thread-startup race, then recreates 50 rendered surfaces, performs 2,500 native +resizes, closes each shell during active output, and injects five Chromium +renderer deaths while retaining the native terminal host. It writes a JSON +report under `artifacts` and fails on unexpected renderer loss, unresponsive +windows, native renderer health failures, a surface that never swaps a real +WGL frame, or retained memory growth above the limit. + +`npm run stress:cycles` repeats the test in five fresh Electron processes for +cold-start coverage. The aggregate report is +`artifacts\stress-cycles.json`. diff --git a/example/electron-embed-windows/binding.gyp b/example/electron-embed-windows/binding.gyp new file mode 100644 index 00000000000..cb473dc6287 --- /dev/null +++ b/example/electron-embed-windows/binding.gyp @@ -0,0 +1,34 @@ +{ + "variables": { + "ghostty_root%": " console.error(`[electron-libghostty] ${message}`) +trace('main module loaded') + +delete process.env.NO_COLOR + +let window +let terminal +let diagnosticsTimer +const expectedRendererDeaths = new Set() +const artifacts = path.join(__dirname, 'artifacts') +const stressMode = process.argv.includes('--stress') +const integerArgument = (name, fallback) => { + const argument = process.argv.find((value) => value.startsWith(`${name}=`)) + const parsed = Number.parseInt(argument?.split('=')[1], 10) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback +} +const stressIterations = integerArgument('--stress-iterations', 50) +const stressResizes = integerArgument('--stress-resizes', 50) +const stressRendererDeaths = integerArgument('--stress-renderer-deaths', 5) +const stressImmediateDestroys = integerArgument('--stress-immediate-destroys', 25) +const stressCycle = integerArgument('--stress-cycle', 0) +const diagnostics = { + renderProcessGone: [], + unexpectedRendererDeaths: [], + unresponsive: [], + native: [], + memory: [] +} + +const terminalBounds = () => { + const [width, height] = window.getContentSize() + return { + x: 16, + y: 68, + width: Math.max(360, Math.floor(width * 0.62) - 22), + height: Math.max(260, height - 84) + } +} + +const nextTurn = () => new Promise((resolve) => setImmediate(resolve)) + +async function waitForNativeFrame(handle, timeoutMs = 2000) { + const deadline = Date.now() + timeoutMs + let native + do { + native = ghostty.diagnostics(handle) + if (native.rendererHealthy === false) { + throw new Error(`libghostty renderer became unhealthy: ${JSON.stringify(native)}`) + } + if (native.swaps > 0n) return native + await new Promise((resolve) => setTimeout(resolve, 10)) + } while (Date.now() < deadline) + throw new Error(`libghostty produced no WGL frame within ${timeoutMs}ms`) +} + +async function loadRenderer() { + await window.loadFile(path.join(__dirname, 'renderer.html')) +} + +async function publishDiagnostics() { + if (!terminal || window.webContents.isDestroyed()) return + const native = ghostty.diagnostics(terminal) + const display = { + ...native, + swaps: native.swaps.toString(), + electron: process.versions.electron, + chrome: process.versions.chrome + } + diagnostics.native.push(display) + await window.webContents.executeJavaScript( + `document.querySelector('#status').textContent = ${JSON.stringify(JSON.stringify(display, null, 2))}` + ) +} + +async function sampleMemory(iteration) { + diagnostics.memory.push({ + iteration, + browser: await process.getProcessMemoryInfo(), + processes: app.getAppMetrics().map((metric) => ({ + pid: metric.pid, + type: metric.type, + memory: metric.memory + })) + }) +} + +function createTerminal() { + trace('reading BrowserWindow native handle') + const nativeHandle = window.getNativeWindowHandle() + trace(`native handle acquired (${nativeHandle.length} bytes)`) + const bounds = terminalBounds() + trace(`creating terminal at ${JSON.stringify(bounds)}`) + return ghostty.create(nativeHandle, { + ...bounds, + workingDirectory: process.cwd(), + command: 'cmd.exe' + }) +} + +async function crashAndRecoverRenderer(sequence) { + const death = once(window.webContents, 'render-process-gone') + expectedRendererDeaths.add(sequence) + window.webContents.forcefullyCrashRenderer() + await death + await loadRenderer() + if (terminal) ghostty.setBounds(terminal, terminalBounds()) + if (terminal) await waitForNativeFrame(terminal) + await publishDiagnostics() +} + +async function runStress() { + await fs.mkdir(artifacts, { recursive: true }) + await sampleMemory(0) + for (let immediate = 1; immediate <= stressImmediateDestroys; immediate += 1) { + terminal = createTerminal() + ghostty.destroy(terminal) + terminal = undefined + if (immediate % 5 === 0) await nextTurn() + } + let injectedRendererDeaths = 0 + for (let iteration = 1; iteration <= stressIterations; iteration += 1) { + terminal = createTerminal() + ghostty.sendText( + terminal, + `echo ghostty-stress-${stressCycle}-${iteration} & ` + + 'powershell.exe -NoProfile -Command "1..400 | ForEach-Object { Write-Output (\'row-{0:D4}\' -f $_) }"\r' + ) + for (let resize = 0; resize < stressResizes; resize += 1) { + const bounds = terminalBounds() + ghostty.setBounds(terminal, { + ...bounds, + width: Math.max(360, bounds.width - ((resize * 17) % 220)), + height: Math.max(260, bounds.height - ((resize * 13) % 180)) + }) + if (resize % 5 === 0) await nextTurn() + } + if ( + stressRendererDeaths > 0 && + injectedRendererDeaths < stressRendererDeaths && + iteration % Math.max(1, Math.floor(stressIterations / stressRendererDeaths)) === 0 + ) { + injectedRendererDeaths += 1 + await crashAndRecoverRenderer(injectedRendererDeaths) + } + const native = await waitForNativeFrame(terminal) + diagnostics.native.push({ + iteration, + ...native, + swaps: native.swaps.toString() + }) + ghostty.destroy(terminal) + terminal = undefined + await nextTurn() + if (iteration % 5 === 0 || iteration === stressIterations) { + await sampleMemory(iteration) + } + } + + const totalWorkingSet = (sample) => sample.processes.reduce( + (total, metric) => total + (metric.memory?.workingSetSize || 0), + 0 + ) + const first = diagnostics.memory.at(0) + const last = diagnostics.memory.at(-1) + const warmup = diagnostics.memory.find( + (sample) => sample.iteration >= Math.min(10, stressIterations) + ) || first + const retainedGrowthAfterWarmupMB = + (totalWorkingSet(last) - totalWorkingSet(warmup)) / 1024 + const peakGrowthAfterWarmupMB = ( + Math.max(...diagnostics.memory + .filter((sample) => sample.iteration >= warmup.iteration) + .map(totalWorkingSet)) - totalWorkingSet(warmup) + ) / 1024 + const maxGrowthMB = Number.parseInt( + process.env.GHOSTTY_STRESS_MAX_RETAINED_GROWTH_MB || '96', + 10 + ) + const nativeFailures = diagnostics.native.filter( + (sample) => sample.rendererHealthy === false || + sample.realLibghostty === false || + BigInt(sample.swaps) === 0n + ) + const report = { + electron: process.versions.electron, + chrome: process.versions.chrome, + cycle: stressCycle, + immediateDestroys: stressImmediateDestroys, + surfacesCreated: stressImmediateDestroys + stressIterations, + iterations: stressIterations, + resizesPerIteration: stressResizes, + injectedRendererDeaths, + retainedGrowthAfterWarmupMB, + peakGrowthAfterWarmupMB, + maxGrowthMB, + diagnostics, + pass: diagnostics.unexpectedRendererDeaths.length === 0 && + diagnostics.unresponsive.length === 0 && + nativeFailures.length === 0 && + retainedGrowthAfterWarmupMB <= maxGrowthMB && + peakGrowthAfterWarmupMB <= maxGrowthMB + } + await fs.writeFile( + path.join(artifacts, `stress-${stressCycle}.json`), + `${JSON.stringify(report, null, 2)}\n` + ) + if (!report.pass) throw new Error(`Windows libghostty stress failed: ${JSON.stringify({ + unexpectedRendererDeaths: diagnostics.unexpectedRendererDeaths.length, + unresponsive: diagnostics.unresponsive.length, + nativeFailures: nativeFailures.length, + retainedGrowthAfterWarmupMB, + peakGrowthAfterWarmupMB + })}`) +} + +app.whenReady().then(async () => { + trace('app ready') + window = new BrowserWindow({ + width: 1280, + height: 800, + show: false, + backgroundColor: '#090c10', + webPreferences: { + contextIsolation: true, + sandbox: true + } + }) + trace('BrowserWindow created') + + window.webContents.on('render-process-gone', (_event, details) => { + trace(`renderer gone: ${JSON.stringify(details)}`) + diagnostics.renderProcessGone.push(details) + if (expectedRendererDeaths.size > 0) { + const first = expectedRendererDeaths.values().next().value + expectedRendererDeaths.delete(first) + } else { + diagnostics.unexpectedRendererDeaths.push(details) + } + }) + window.on('unresponsive', () => { + trace('BrowserWindow unresponsive') + diagnostics.unresponsive.push({ time: new Date().toISOString() }) + }) + + const readyToShow = once(window, 'ready-to-show') + await Promise.all([loadRenderer().then(() => trace('renderer document loaded')), readyToShow]) + trace('ready-to-show received') + terminal = createTerminal() + trace('native terminal created') + window.show() + ghostty.focus(terminal) + if (!stressMode) { + await publishDiagnostics() + diagnosticsTimer = setInterval(() => { + publishDiagnostics().catch((error) => trace(`diagnostics failed: ${error}`)) + }, 1000) + } + + window.on('resize', () => { + if (terminal) ghostty.setBounds(terminal, terminalBounds()) + }) + // Tear down while Electron's parent HWND and the child's HDC are valid. + // The later `closed` event is too late because Chromium has destroyed the + // native parent by then. + window.on('close', () => { + trace('BrowserWindow close') + clearInterval(diagnosticsTimer) + diagnosticsTimer = undefined + if (terminal) ghostty.destroy(terminal) + terminal = undefined + }) + window.on('closed', () => { + trace('BrowserWindow closed') + window = undefined + }) + + if (stressMode) { + ghostty.destroy(terminal) + terminal = undefined + await runStress() + app.exit(0) + } +}).catch((error) => { + console.error(error) + app.exit(1) +}) + +app.on('window-all-closed', () => app.quit()) +app.on('child-process-gone', (_event, details) => { + trace(`child process gone: ${JSON.stringify(details)}`) +}) +app.on('before-quit', () => trace('app before-quit')) +app.on('will-quit', () => trace('app will-quit')) diff --git a/example/electron-embed-windows/package-lock.json b/example/electron-embed-windows/package-lock.json new file mode 100644 index 00000000000..f240334ff9a --- /dev/null +++ b/example/electron-embed-windows/package-lock.json @@ -0,0 +1,411 @@ +{ + "name": "electron-libghostty-windows-embed", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "electron-libghostty-windows-embed", + "version": "0.0.1", + "hasInstallScript": true, + "devDependencies": { + "electron": "43.1.0", + "node-gyp": "13.0.1" + } + }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.4.tgz", + "integrity": "sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/get": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz", + "integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/abbrev": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-5.0.0.tgz", + "integrity": "sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron": { + "version": "43.1.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-43.1.0.tgz", + "integrity": "sha512-DPfxpQLd4NL3BJ8DBxYAfmLUKKesF5Rx9dQx5FyczAP8bhOPScjHE48GArVeXu68LlAainuwkmQTQvdZwpIIAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-gyp": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-13.0.1.tgz", + "integrity": "sha512-piOr0S10qy5THB+q5BdqkoOx65XL/tjTMUAit3vciPNp+snTOBnGunWH1Rz7XZUxf2T9uFrfT/Ty4+aC3yPeyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^10.0.0", + "proc-log": "^7.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^8.4.1", + "which": "^7.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/node-gyp/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.7.0.tgz", + "integrity": "sha512-N7iQtfyLhIMOFgQubvmLV26svHpO0bqKnAiWotTQCVKCmWrcGbBotPuW1x+xwYZ2VHdSTVUfPQQnlEt1/LouTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/nopt": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-10.0.1.tgz", + "integrity": "sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^5.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proc-log": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-7.0.0.tgz", + "integrity": "sha512-FYgfaA69XZ93zaXLoMNQ+ViDXGGBgR8aLh03txzcFhV+9xOXx7+8DLCULrKKpR9+GsH9ZfHm82aSUPpozX0Ztg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/tar": { + "version": "7.5.20", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", + "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-7.0.0.tgz", + "integrity": "sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/example/electron-embed-windows/package.json b/example/electron-embed-windows/package.json new file mode 100644 index 00000000000..d2d0424e16b --- /dev/null +++ b/example/electron-embed-windows/package.json @@ -0,0 +1,21 @@ +{ + "name": "electron-libghostty-windows-embed", + "private": true, + "version": "0.0.1", + "description": "Real libghostty WGL child HWND embedded in Electron", + "main": "main.js", + "scripts": { + "install": "node -e \"console.log('Run npm run build:ghostty and npm run build after installing dependencies')\"", + "build:ghostty": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/build-ghostty.ps1", + "build:addon": "node-gyp rebuild --target=43.1.0 --dist-url=https://electronjs.org/headers", + "build": "npm run build:addon", + "deploy:mesa": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/deploy-mesa.ps1", + "start": "electron .", + "stress": "electron . --stress", + "stress:cycles": "node scripts/stress-cycles.mjs" + }, + "devDependencies": { + "electron": "43.1.0", + "node-gyp": "13.0.1" + } +} diff --git a/example/electron-embed-windows/renderer.html b/example/electron-embed-windows/renderer.html new file mode 100644 index 00000000000..039b9bdbed1 --- /dev/null +++ b/example/electron-embed-windows/renderer.html @@ -0,0 +1,61 @@ + + + + + + Electron + native libghostty on Windows + + + +
Native libghostty surface + Chromium UI
+
+
+

Windows HWND/WGL embedding

+

The terminal at left is a native child HWND. Ghostty renders it through an embedder-owned OpenGL context. This page is ordinary Chromium content.

+

No xterm.js dependency exists in this package.

+
Waiting for native diagnostics...
+
+
+ + diff --git a/example/electron-embed-windows/scripts/build-ghostty.ps1 b/example/electron-embed-windows/scripts/build-ghostty.ps1 new file mode 100644 index 00000000000..5faeed3e4c7 --- /dev/null +++ b/example/electron-embed-windows/scripts/build-ghostty.ps1 @@ -0,0 +1,32 @@ +$ErrorActionPreference = "Stop" + +$repo = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path +$zig = $env:GHOSTTY_ZIG +if (-not $zig) { + $command = Get-Command zig.exe -ErrorAction SilentlyContinue + if ($command) { $zig = $command.Source } +} +if (-not $zig -and (Test-Path "C:\tools\zig\zig.exe")) { + $zig = "C:\tools\zig\zig.exe" +} +if (-not $zig) { + throw "Zig 0.15.2 was not found. Set GHOSTTY_ZIG or add zig.exe to PATH." +} + +Push-Location $repo +try { + & $zig build -Doptimize=ReleaseFast +} finally { + Pop-Location +} + +$required = @( + (Join-Path $repo "zig-out\lib\ghostty-internal.dll"), + (Join-Path $repo "zig-out\lib\ghostty-internal.lib"), + (Join-Path $repo "zig-out\include\ghostty.h") +) +foreach ($path in $required) { + if (-not (Test-Path $path)) { + throw "Ghostty build did not produce $path" + } +} diff --git a/example/electron-embed-windows/scripts/deploy-mesa.ps1 b/example/electron-embed-windows/scripts/deploy-mesa.ps1 new file mode 100644 index 00000000000..dd034aa529d --- /dev/null +++ b/example/electron-embed-windows/scripts/deploy-mesa.ps1 @@ -0,0 +1,20 @@ +param( + [string]$MesaDir = $env:GHOSTTY_MESA_DIR +) + +$ErrorActionPreference = "Stop" + +if (-not $MesaDir) { + throw "Set GHOSTTY_MESA_DIR to an extracted Mesa Windows x64 directory." +} + +$openGl = Join-Path $MesaDir "opengl32.dll" +$gallium = Join-Path $MesaDir "libgallium_wgl.dll" +if (-not (Test-Path $openGl) -or -not (Test-Path $gallium)) { + throw "GHOSTTY_MESA_DIR must contain opengl32.dll and libgallium_wgl.dll." +} + +$destination = Join-Path $PSScriptRoot "..\build\Release" +New-Item -ItemType Directory -Force $destination | Out-Null +Copy-Item -Force $openGl (Join-Path $destination "opengl32.mesa.dll") +Copy-Item -Force $gallium (Join-Path $destination "libgallium_wgl.dll") diff --git a/example/electron-embed-windows/scripts/start-software-renderer.cmd b/example/electron-embed-windows/scripts/start-software-renderer.cmd new file mode 100644 index 00000000000..aaa36cb5a01 --- /dev/null +++ b/example/electron-embed-windows/scripts/start-software-renderer.cmd @@ -0,0 +1,18 @@ +@echo off +setlocal +set GALLIUM_DRIVER=llvmpipe +set LIBGL_ALWAYS_SOFTWARE=true +set MESA_LOADER_DRIVER_OVERRIDE=llvmpipe +set GHOSTTY_MESA_OPENGL_PATH=%~dp0..\build\Release\opengl32.mesa.dll +if not exist "%GHOSTTY_MESA_OPENGL_PATH%" ( + echo Missing %GHOSTTY_MESA_OPENGL_PATH%. Run npm run deploy:mesa first. + exit /b 1 +) +if not exist "%~dp0..\build\Release\libgallium_wgl.dll" ( + echo Missing libgallium_wgl.dll. Run npm run deploy:mesa first. + exit /b 1 +) +if not exist "%~dp0..\artifacts" mkdir "%~dp0..\artifacts" +set GHOSTTY_EMBED_TRACE=%~dp0..\artifacts\native.log +del "%GHOSTTY_EMBED_TRACE%" 2>nul +"%~dp0..\node_modules\electron\dist\electron.exe" "%~dp0.." %* --enable-logging --v=1 > "%~dp0..\artifacts\electron.log" 2>&1 diff --git a/example/electron-embed-windows/scripts/stress-cycles.mjs b/example/electron-embed-windows/scripts/stress-cycles.mjs new file mode 100644 index 00000000000..2039ce5c464 --- /dev/null +++ b/example/electron-embed-windows/scripts/stress-cycles.mjs @@ -0,0 +1,136 @@ +import { spawn } from 'node:child_process' +import fs from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' + +const root = path.resolve(import.meta.dirname, '..') +const artifacts = path.join(root, 'artifacts') +const electron = path.join(root, 'node_modules', 'electron', 'dist', 'electron.exe') +const mesaOpenGl = path.join(root, 'build', 'Release', 'opengl32.mesa.dll') +const mesaGallium = path.join(root, 'build', 'Release', 'libgallium_wgl.dll') +const integerArgument = (name, fallback) => { + const argument = process.argv.find((value) => value.startsWith(`${name}=`)) + const parsed = Number.parseInt(argument?.split('=')[1], 10) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback +} +const cycles = integerArgument('--cycles', 5) +const iterations = integerArgument('--iterations', 50) +const resizes = integerArgument('--resizes', 50) +const rendererDeaths = integerArgument('--renderer-deaths', 5) +const immediateDestroys = integerArgument('--immediate-destroys', 25) +const timeoutMs = integerArgument('--timeout-ms', 240000) + +await fs.mkdir(artifacts, { recursive: true }) +const childEnvironment = { ...process.env, NO_COLOR: '' } +try { + await Promise.all([fs.access(mesaOpenGl), fs.access(mesaGallium)]) + childEnvironment.GHOSTTY_MESA_OPENGL_PATH ||= mesaOpenGl + childEnvironment.GALLIUM_DRIVER ||= 'llvmpipe' + childEnvironment.LIBGL_ALWAYS_SOFTWARE ||= 'true' + childEnvironment.MESA_LOADER_DRIVER_OVERRIDE ||= 'llvmpipe' +} catch { + // A production GPU driver can supply OpenGL without the optional Mesa files. +} +const results = [] +for (let cycle = 1; cycle <= cycles; cycle += 1) { + const reportPath = path.join(artifacts, `stress-${cycle}.json`) + const stdoutPath = path.join(artifacts, `stress-${cycle}.stdout.log`) + const stderrPath = path.join(artifacts, `stress-${cycle}.stderr.log`) + const nativeTracePath = path.join(artifacts, `stress-${cycle}.native.log`) + await Promise.all([ + fs.rm(reportPath, { force: true }), + fs.rm(stdoutPath, { force: true }), + fs.rm(stderrPath, { force: true }), + fs.rm(nativeTracePath, { force: true }) + ]) + const stdout = await fs.open(stdoutPath, 'w') + const stderr = await fs.open(stderrPath, 'w') + const started = Date.now() + const result = await new Promise((resolve) => { + let settled = false + const finish = (value) => { + if (settled) return + settled = true + resolve(value) + } + const child = spawn(electron, [ + root, + '--stress', + `--stress-cycle=${cycle}`, + `--stress-iterations=${iterations}`, + `--stress-resizes=${resizes}`, + `--stress-renderer-deaths=${rendererDeaths}`, + `--stress-immediate-destroys=${immediateDestroys}` + ], { + cwd: root, + env: { ...childEnvironment, GHOSTTY_EMBED_TRACE: nativeTracePath }, + stdio: ['ignore', stdout.fd, stderr.fd], + windowsHide: false + }) + const timeout = setTimeout(() => { + if (process.platform === 'win32' && child.pid) { + spawn('taskkill.exe', ['/pid', String(child.pid), '/T', '/F'], { + stdio: 'ignore', + windowsHide: true + }) + } else { + child.kill('SIGKILL') + } + setTimeout( + () => finish({ cycle, exitCode: null, signal: 'timeout' }), + 10000 + ).unref() + }, timeoutMs) + child.once('exit', (exitCode, signal) => { + clearTimeout(timeout) + finish({ cycle, exitCode, signal }) + }) + child.once('error', (error) => { + clearTimeout(timeout) + finish({ cycle, exitCode: null, signal: null, error: error.message }) + }) + }) + await stdout.close() + await stderr.close() + result.durationMs = Date.now() - started + try { + result.report = JSON.parse( + await fs.readFile(reportPath, 'utf8') + ) + } catch (error) { + result.reportError = error.message + } + result.pass = result.exitCode === 0 && result.report?.pass === true + results.push(result) + if (!result.pass) break +} + +const summary = { + cyclesRequested: cycles, + cyclesCompleted: results.length, + iterationsPerCycle: iterations, + resizesPerIteration: resizes, + rendererDeathsPerCycle: rendererDeaths, + immediateDestroysPerCycle: immediateDestroys, + totalSurfaces: results.reduce( + (sum, value) => sum + (value.report?.surfacesCreated || 0), + 0 + ), + totalResizes: results.reduce( + (sum, value) => sum + + (value.report?.iterations || 0) * (value.report?.resizesPerIteration || 0), + 0 + ), + totalInjectedRendererDeaths: results.reduce( + (sum, value) => sum + (value.report?.injectedRendererDeaths || 0), + 0 + ), + results, + pass: results.length === cycles && results.every((value) => value.pass) +} +await fs.writeFile( + path.join(artifacts, 'stress-cycles.json'), + `${JSON.stringify(summary, null, 2)}\n` +) +console.log(JSON.stringify(summary, null, 2)) +if (!summary.pass) process.exitCode = 1 diff --git a/example/electron-embed-windows/src/libghostty_embed_win.cc b/example/electron-embed-windows/src/libghostty_embed_win.cc new file mode 100644 index 00000000000..68177e4d7cf --- /dev/null +++ b/example/electron-embed-windows/src/libghostty_embed_win.cc @@ -0,0 +1,1245 @@ +#define WIN32_LEAN_AND_MEAN +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "ghostty.h" + +namespace { + +constexpr wchar_t kTerminalWindowClass[] = + L"GhosttyElectronNativeTerminalWindow"; +constexpr UINT kWakeupMessage = WM_APP + 0x317; +constexpr UINT kCopyCommand = 1; +constexpr UINT kPasteCommand = 2; + +constexpr int kWglContextMajorVersionArb = 0x2091; +constexpr int kWglContextMinorVersionArb = 0x2092; +constexpr int kWglContextProfileMaskArb = 0x9126; +constexpr int kWglContextCoreProfileBitArb = 0x00000001; +constexpr int kWglContextCompatibilityProfileBitArb = 0x00000002; + +using WglCreateContextAttribsArb = HGLRC(WINAPI*)(HDC, HGLRC, const int*); +using WglChoosePixelFormatFn = int(WINAPI*)(HDC, const PIXELFORMATDESCRIPTOR*); +using WglCreateContextFn = HGLRC(WINAPI*)(HDC); +using WglDeleteContextFn = BOOL(WINAPI*)(HGLRC); +using WglGetCurrentContextFn = HGLRC(WINAPI*)(); +using WglGetProcAddressFn = PROC(WINAPI*)(LPCSTR); +using WglMakeCurrentFn = BOOL(WINAPI*)(HDC, HGLRC); +using WglSetPixelFormatFn = BOOL(WINAPI*)(HDC, + int, + const PIXELFORMATDESCRIPTOR*); +using WglSwapBuffersFn = BOOL(WINAPI*)(HDC); +using GlGetStringFn = const GLubyte*(APIENTRY*)(GLenum); + +void Trace(const char* message) { + char trace_path[MAX_PATH] = {}; + if (GetEnvironmentVariableA("GHOSTTY_EMBED_TRACE", trace_path, + std::size(trace_path)) != 0) { + HANDLE trace = CreateFileA(trace_path, FILE_APPEND_DATA, + FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, + OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (trace != INVALID_HANDLE_VALUE) { + const char prefix[] = "[ghostty-embed] "; + const char newline[] = "\r\n"; + DWORD written = 0; + WriteFile(trace, prefix, sizeof(prefix) - 1, &written, nullptr); + WriteFile(trace, message, static_cast(std::strlen(message)), + &written, nullptr); + WriteFile(trace, newline, sizeof(newline) - 1, &written, nullptr); + CloseHandle(trace); + } + } +} + +struct GhosttyHost { + ghostty_config_t config = nullptr; + ghostty_app_t app = nullptr; + ghostty_surface_t surface = nullptr; + HWND parent = nullptr; + HWND child = nullptr; + HDC device_context = nullptr; + HGLRC render_context = nullptr; + HMODULE opengl_module = nullptr; + WglChoosePixelFormatFn wgl_choose_pixel_format = nullptr; + WglCreateContextFn wgl_create_context = nullptr; + WglDeleteContextFn wgl_delete_context = nullptr; + WglGetCurrentContextFn wgl_get_current_context = nullptr; + WglGetProcAddressFn wgl_get_proc_address = nullptr; + WglMakeCurrentFn wgl_make_current = nullptr; + WglSetPixelFormatFn wgl_set_pixel_format = nullptr; + WglSwapBuffersFn wgl_swap_buffers = nullptr; + GlGetStringFn gl_get_string = nullptr; + CRITICAL_SECTION context_lock = {}; + bool context_lock_initialized = false; + std::atomic closing = false; + std::atomic renderer_healthy = true; + std::atomic swaps = 0; + bool left_captured = false; + bool right_captured = false; + wchar_t pending_high_surrogate = 0; + int modifier_latch = GHOSTTY_MODS_NONE; + std::string gl_version; +}; + +void Throw(napi_env env, const char* message) { + napi_throw_error(env, "ERR_GHOSTTY_EMBED", message); +} + +bool GetNamedDouble(napi_env env, + napi_value object, + const char* name, + double* result) { + napi_value value; + if (napi_get_named_property(env, object, name, &value) != napi_ok) + return false; + return napi_get_value_double(env, value, result) == napi_ok; +} + +std::string GetNamedString(napi_env env, napi_value object, const char* name) { + napi_value value; + if (napi_get_named_property(env, object, name, &value) != napi_ok) + return {}; + + size_t length = 0; + if (napi_get_value_string_utf8(env, value, nullptr, 0, &length) != napi_ok) + return {}; + + std::string result(length + 1, '\0'); + if (napi_get_value_string_utf8(env, value, result.data(), result.size(), + &length) != napi_ok) { + return {}; + } + result.resize(length); + return result; +} + +std::string WideToUtf8(const wchar_t* value, int length = -1) { + if (!value) + return {}; + const int output_length = + WideCharToMultiByte(CP_UTF8, 0, value, length, nullptr, 0, nullptr, + nullptr); + if (output_length <= 0) + return {}; + std::string result(output_length, '\0'); + WideCharToMultiByte(CP_UTF8, 0, value, length, result.data(), output_length, + nullptr, nullptr); + if (length == -1 && !result.empty() && result.back() == '\0') + result.pop_back(); + return result; +} + +std::wstring Utf8ToWide(const char* value) { + if (!value) + return {}; + const int output_length = + MultiByteToWideChar(CP_UTF8, 0, value, -1, nullptr, 0); + if (output_length <= 0) + return {}; + std::wstring result(output_length, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, value, -1, result.data(), output_length); + return result; +} + +ghostty_input_mods_e CurrentModifiers(const GhosttyHost* host) { + int mods = host ? host->modifier_latch : GHOSTTY_MODS_NONE; + if (GetKeyState(VK_CAPITAL) & 1) + mods |= GHOSTTY_MODS_CAPS; + if (GetKeyState(VK_NUMLOCK) & 1) + mods |= GHOSTTY_MODS_NUM; + return static_cast(mods); +} + +void UpdateModifierLatch(GhosttyHost* host, + WPARAM virtual_key, + LPARAM lparam, + bool down) { + if (!host) + return; + int bits = 0; + switch (virtual_key) { + case VK_SHIFT: + case VK_LSHIFT: + case VK_RSHIFT: { + bits = GHOSTTY_MODS_SHIFT; + const UINT scan_code = static_cast((lparam >> 16) & 0xff); + const UINT resolved = virtual_key == VK_SHIFT + ? MapVirtualKeyW(scan_code, MAPVK_VSC_TO_VK_EX) + : static_cast(virtual_key); + if (resolved == VK_RSHIFT) + bits |= GHOSTTY_MODS_SHIFT_RIGHT; + break; + } + case VK_CONTROL: + case VK_LCONTROL: + case VK_RCONTROL: + bits = GHOSTTY_MODS_CTRL; + if (virtual_key == VK_RCONTROL || (lparam & (1LL << 24))) + bits |= GHOSTTY_MODS_CTRL_RIGHT; + break; + case VK_MENU: + case VK_LMENU: + case VK_RMENU: + bits = GHOSTTY_MODS_ALT; + if (virtual_key == VK_RMENU || (lparam & (1LL << 24))) + bits |= GHOSTTY_MODS_ALT_RIGHT; + break; + case VK_LWIN: + case VK_RWIN: + bits = GHOSTTY_MODS_SUPER; + if (virtual_key == VK_RWIN) + bits |= GHOSTTY_MODS_SUPER_RIGHT; + break; + default: + return; + } + if (down) + host->modifier_latch |= bits; + else + host->modifier_latch &= ~bits; +} + +uint32_t NativeScanCode(LPARAM lparam) { + uint32_t scan_code = static_cast((lparam >> 16) & 0xff); + if ((lparam & (1LL << 24)) != 0) + scan_code |= 0xe000; + return scan_code; +} + +uint32_t UnshiftedCodepoint(WPARAM virtual_key) { + if (virtual_key >= 'A' && virtual_key <= 'Z') + return static_cast('a' + (virtual_key - 'A')); + if (virtual_key >= '0' && virtual_key <= '9') + return static_cast(virtual_key); + return 0; +} + +bool IsTextProducingKey(WPARAM virtual_key) { + return virtual_key == VK_SPACE || + (virtual_key >= '0' && virtual_key <= '9') || + (virtual_key >= 'A' && virtual_key <= 'Z') || + (virtual_key >= VK_NUMPAD0 && virtual_key <= VK_DIVIDE) || + (virtual_key >= VK_OEM_1 && virtual_key <= VK_OEM_8) || + virtual_key == VK_OEM_102; +} + +bool HasCommandModifier(const GhosttyHost* host) { + return host && + (host->modifier_latch & + (GHOSTTY_MODS_CTRL | GHOSTTY_MODS_ALT | GHOSTTY_MODS_SUPER)); +} + +int DipToPixel(HWND window, double value) { + const UINT dpi = window ? GetDpiForWindow(window) : 96; + const double scale = dpi > 0 ? static_cast(dpi) / 96.0 : 1.0; + return static_cast(std::lround(value * scale)); +} + +void SendMousePosition(GhosttyHost* host, LPARAM lparam) { + if (!host || !host->surface) + return; + ghostty_surface_mouse_pos(host->surface, GET_X_LPARAM(lparam), + GET_Y_LPARAM(lparam), CurrentModifiers(host)); +} + +void UpdateSurfaceMetrics(GhosttyHost* host) { + if (!host || !host->surface || !host->child) + return; + RECT bounds = {}; + if (!GetClientRect(host->child, &bounds)) + return; + const UINT dpi = GetDpiForWindow(host->child); + const double scale = dpi > 0 ? static_cast(dpi) / 96.0 : 1.0; + ghostty_surface_set_content_scale(host->surface, scale, scale); + ghostty_surface_set_size( + host->surface, static_cast(bounds.right - bounds.left), + static_cast(bounds.bottom - bounds.top)); +} + +void* WglGetProcAddress(void* userdata, const char* name) { + auto* host = static_cast(userdata); + if (!host || !name || !host->wgl_get_proc_address) + return nullptr; + PROC proc = host->wgl_get_proc_address(name); + if (proc && proc != reinterpret_cast(1) && + proc != reinterpret_cast(2) && + proc != reinterpret_cast(3) && + proc != reinterpret_cast(-1)) { + return reinterpret_cast(proc); + } + return host->opengl_module + ? reinterpret_cast(GetProcAddress(host->opengl_module, name)) + : nullptr; +} + +bool WglMakeCurrent(void* userdata) { + auto* host = static_cast(userdata); + if (!host || !host->device_context || !host->render_context) + return false; + EnterCriticalSection(&host->context_lock); + if (!host->wgl_make_current(host->device_context, host->render_context)) { + LeaveCriticalSection(&host->context_lock); + return false; + } + return true; +} + +void WglClearCurrent(void* userdata) { + auto* host = static_cast(userdata); + if (!host) + return; + host->wgl_make_current(nullptr, nullptr); + LeaveCriticalSection(&host->context_lock); +} + +void WglSwapBuffers(void* userdata) { + auto* host = static_cast(userdata); + if (!host || !host->device_context) + return; + if (host->wgl_swap_buffers(host->device_context)) + host->swaps.fetch_add(1, std::memory_order_relaxed); + else + host->renderer_healthy.store(false, std::memory_order_release); +} + +bool VersionAtLeast43(const char* version) { + if (!version) + return false; + int major = 0; + int minor = 0; + if (sscanf_s(version, "%d.%d", &major, &minor) != 2) + return false; + return major > 4 || (major == 4 && minor >= 3); +} + +bool LoadWglApi(GhosttyHost* host, std::string* error) { + wchar_t override_path[MAX_PATH] = {}; + const DWORD override_length = GetEnvironmentVariableW( + L"GHOSTTY_MESA_OPENGL_PATH", override_path, std::size(override_path)); + if (override_length > 0 && override_length < std::size(override_path)) { + host->opengl_module = LoadLibraryExW( + override_path, nullptr, LOAD_WITH_ALTERED_SEARCH_PATH); + } else { + host->opengl_module = LoadLibraryW(L"opengl32.dll"); + } + if (!host->opengl_module) { + *error = "Unable to load the requested OpenGL implementation"; + return false; + } + + const auto load = [host](const char* name) { + return GetProcAddress(host->opengl_module, name); + }; + host->wgl_create_context = + reinterpret_cast(load("wglCreateContext")); + host->wgl_choose_pixel_format = reinterpret_cast( + load("wglChoosePixelFormat")); + host->wgl_delete_context = + reinterpret_cast(load("wglDeleteContext")); + host->wgl_get_current_context = reinterpret_cast( + load("wglGetCurrentContext")); + host->wgl_get_proc_address = + reinterpret_cast(load("wglGetProcAddress")); + host->wgl_make_current = + reinterpret_cast(load("wglMakeCurrent")); + host->wgl_set_pixel_format = reinterpret_cast( + load("wglSetPixelFormat")); + host->wgl_swap_buffers = + reinterpret_cast(load("wglSwapBuffers")); + host->gl_get_string = + reinterpret_cast(load("glGetString")); + if (!host->wgl_choose_pixel_format || !host->wgl_create_context || + !host->wgl_delete_context || + !host->wgl_get_current_context || !host->wgl_get_proc_address || + !host->wgl_make_current || !host->wgl_set_pixel_format || + !host->wgl_swap_buffers || !host->gl_get_string) { + *error = "The requested OpenGL DLL is missing required WGL exports"; + return false; + } + return true; +} + +bool InitializeWgl(GhosttyHost* host, std::string* error) { + if (!LoadWglApi(host, error)) + return false; + host->device_context = GetDC(host->child); + if (!host->device_context) { + *error = "GetDC failed for the native terminal child HWND"; + return false; + } + + PIXELFORMATDESCRIPTOR format = {}; + format.nSize = sizeof(format); + format.nVersion = 1; + format.dwFlags = + PFD_DRAW_TO_WINDOW | PFD_SUPPORT_OPENGL | PFD_DOUBLEBUFFER; + format.iPixelType = PFD_TYPE_RGBA; + format.cColorBits = 32; + format.cAlphaBits = 8; + format.cDepthBits = 24; + format.cStencilBits = 8; + format.iLayerType = PFD_MAIN_PLANE; + + const int pixel_format = + host->wgl_choose_pixel_format(host->device_context, &format); + if (pixel_format == 0 || + !host->wgl_set_pixel_format(host->device_context, pixel_format, + &format)) { + *error = "Unable to set a double-buffered WGL pixel format"; + return false; + } + + HGLRC legacy = host->wgl_create_context(host->device_context); + if (!legacy || !host->wgl_make_current(host->device_context, legacy)) { + *error = "Unable to create the WGL bootstrap context"; + if (legacy) + host->wgl_delete_context(legacy); + return false; + } + + auto create_context = reinterpret_cast( + host->wgl_get_proc_address("wglCreateContextAttribsARB")); + HGLRC modern = nullptr; + if (create_context) { + const int profiles[] = {kWglContextCoreProfileBitArb, + kWglContextCompatibilityProfileBitArb}; + const int versions[][2] = {{4, 5}, {4, 3}}; + for (const auto& version : versions) { + for (const int profile : profiles) { + const int attributes[] = { + kWglContextMajorVersionArb, + version[0], + kWglContextMinorVersionArb, + version[1], + kWglContextProfileMaskArb, + profile, + 0, + }; + modern = create_context(host->device_context, nullptr, attributes); + if (modern) + break; + } + if (modern) + break; + } + } + + if (modern) { + host->wgl_make_current(nullptr, nullptr); + host->wgl_delete_context(legacy); + host->render_context = modern; + if (!host->wgl_make_current(host->device_context, + host->render_context)) { + *error = "Unable to activate the OpenGL 4.3 WGL context"; + return false; + } + } else { + host->render_context = legacy; + } + + const char* version = + reinterpret_cast(host->gl_get_string(GL_VERSION)); + host->gl_version = version ? version : "unknown"; + if (!VersionAtLeast43(version)) { + *error = "Ghostty requires OpenGL 4.3; WGL reported " + host->gl_version; + host->wgl_make_current(nullptr, nullptr); + return false; + } + host->wgl_make_current(nullptr, nullptr); + return true; +} + +void DestroyWgl(GhosttyHost* host) { + if (!host) + return; + // Surface.deinit joins the renderer thread and then enters the renderer + // once on the caller thread so GPU resources can be freed. Balance that + // final make_current before taking the lock for context destruction. + if (host->render_context && host->wgl_get_current_context && + host->wgl_get_current_context() == host->render_context) { + host->wgl_make_current(nullptr, nullptr); + LeaveCriticalSection(&host->context_lock); + } + if (host->context_lock_initialized) + EnterCriticalSection(&host->context_lock); + if (host->render_context) { + host->wgl_delete_context(host->render_context); + host->render_context = nullptr; + } + if (host->device_context && host->child) { + ReleaseDC(host->child, host->device_context); + host->device_context = nullptr; + } + if (host->context_lock_initialized) { + LeaveCriticalSection(&host->context_lock); + DeleteCriticalSection(&host->context_lock); + host->context_lock_initialized = false; + } + if (host->opengl_module) { + FreeLibrary(host->opengl_module); + host->opengl_module = nullptr; + } +} + +bool ReadClipboard(void* userdata, ghostty_clipboard_e type, void* state) { + Trace("clipboard: read requested"); + auto* host = static_cast(userdata); + if (!host || !host->surface || type != GHOSTTY_CLIPBOARD_STANDARD) + return false; + if (!OpenClipboard(host->child)) + return false; + HANDLE value_handle = GetClipboardData(CF_UNICODETEXT); + const wchar_t* value = value_handle + ? static_cast(GlobalLock(value_handle)) + : nullptr; + const std::string utf8 = value ? WideToUtf8(value) : std::string(); + if (value) + GlobalUnlock(value_handle); + CloseClipboard(); + if (utf8.empty()) + return false; + Trace("clipboard: read completed"); + ghostty_surface_complete_clipboard_request(host->surface, utf8.c_str(), state, + false); + return true; +} + +void ConfirmReadClipboard(void* userdata, + const char* value, + void* state, + ghostty_clipboard_request_e) { + Trace("clipboard: read confirmed"); + auto* host = static_cast(userdata); + if (host && host->surface) + ghostty_surface_complete_clipboard_request(host->surface, value, state, + true); +} + +void WriteClipboard(void* userdata, + ghostty_clipboard_e type, + const ghostty_clipboard_content_s* content, + size_t length, + bool) { + Trace("clipboard: write requested"); + auto* host = static_cast(userdata); + if (!host || type != GHOSTTY_CLIPBOARD_STANDARD || !content) + return; + for (size_t index = 0; index < length; ++index) { + if (!content[index].mime || !content[index].data || + std::strcmp(content[index].mime, "text/plain") != 0) { + continue; + } + const std::wstring wide = Utf8ToWide(content[index].data); + if (wide.empty() || !OpenClipboard(host->child)) + return; + EmptyClipboard(); + const SIZE_T bytes = wide.size() * sizeof(wchar_t); + HGLOBAL allocation = GlobalAlloc(GMEM_MOVEABLE, bytes); + if (allocation) { + void* destination = GlobalLock(allocation); + if (destination) { + std::memcpy(destination, wide.data(), bytes); + GlobalUnlock(allocation); + if (!SetClipboardData(CF_UNICODETEXT, allocation)) + GlobalFree(allocation); + else + Trace("clipboard: write completed"); + } else { + GlobalFree(allocation); + } + } + CloseClipboard(); + return; + } +} + +void Wakeup(void* userdata) { + auto* host = static_cast(userdata); + if (host && !host->closing.load(std::memory_order_acquire) && host->child) + PostMessageW(host->child, kWakeupMessage, 0, 0); +} + +bool Action(ghostty_app_t app, + ghostty_target_s, + ghostty_action_s action) { + auto* host = static_cast(ghostty_app_userdata(app)); + if (!host) + return false; + if (host->closing.load(std::memory_order_acquire)) { + return action.tag == GHOSTTY_ACTION_RENDER || + action.tag == GHOSTTY_ACTION_RENDERER_HEALTH; + } + switch (action.tag) { + case GHOSTTY_ACTION_RENDER: + if (host->surface) + ghostty_surface_refresh(host->surface); + return true; + case GHOSTTY_ACTION_RENDERER_HEALTH: + host->renderer_healthy.store( + action.action.renderer_health == GHOSTTY_RENDERER_HEALTH_HEALTHY, + std::memory_order_release); + return true; + default: + return false; + } +} + +void CloseSurface(void*, bool) {} + +bool EnsureGhosttyInitialized() { + static std::once_flag once; + static int result = -1; + std::call_once(once, [] { + char process_name[] = "electron-libghostty-windows"; + char* argv[] = {process_name}; + result = ghostty_init(1, argv); + }); + return result == GHOSTTY_SUCCESS; +} + +void SendKey(GhosttyHost* host, + ghostty_input_action_e action, + WPARAM virtual_key, + LPARAM lparam) { + if (!host || !host->surface) + return; + ghostty_input_key_s key = {}; + key.action = action; + key.mods = CurrentModifiers(host); + key.consumed_mods = GHOSTTY_MODS_NONE; + key.keycode = NativeScanCode(lparam); + key.unshifted_codepoint = UnshiftedCodepoint(virtual_key); + key.text = nullptr; + ghostty_surface_key(host->surface, key); +} + +void SendUtf16Character(GhosttyHost* host, wchar_t character) { + if (!host || !host->surface) + return; + if (character >= 0xd800 && character <= 0xdbff) { + host->pending_high_surrogate = character; + return; + } + wchar_t utf16[3] = {}; + int length = 1; + if (character >= 0xdc00 && character <= 0xdfff && + host->pending_high_surrogate) { + utf16[0] = host->pending_high_surrogate; + utf16[1] = character; + length = 2; + } else { + utf16[0] = character; + } + host->pending_high_surrogate = 0; + const std::string utf8 = WideToUtf8(utf16, length); + if (!utf8.empty()) + ghostty_surface_text_input(host->surface, utf8.data(), utf8.size()); +} + +void InvokeBinding(GhosttyHost* host, const char* action) { + if (!host || !host->surface || !action) + return; + if (ghostty_surface_binding_action(host->surface, action, + std::strlen(action))) { + Trace("binding: action handled"); + } else { + Trace("binding: action rejected"); + } +} + +void ShowContextMenu(GhosttyHost* host, LPARAM lparam) { + if (!host || !host->child) + return; + HMENU menu = CreatePopupMenu(); + if (!menu) + return; + const bool has_selection = + host->surface && ghostty_surface_has_selection(host->surface); + AppendMenuW(menu, MF_STRING | (has_selection ? MF_ENABLED : MF_GRAYED), + kCopyCommand, L"Copy"); + AppendMenuW(menu, + MF_STRING | (IsClipboardFormatAvailable(CF_UNICODETEXT) + ? MF_ENABLED + : MF_GRAYED), + kPasteCommand, L"Paste"); + POINT point = {GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam)}; + ClientToScreen(host->child, &point); + const UINT command = TrackPopupMenu( + menu, TPM_RETURNCMD | TPM_RIGHTBUTTON | TPM_NONOTIFY, point.x, point.y, 0, + host->child, nullptr); + DestroyMenu(menu); + if (command == kCopyCommand) + InvokeBinding(host, "copy_to_clipboard"); + else if (command == kPasteCommand) + InvokeBinding(host, "paste_from_clipboard"); +} + +LRESULT CALLBACK TerminalWindowProc(HWND window, + UINT message, + WPARAM wparam, + LPARAM lparam) { + GhosttyHost* host = reinterpret_cast( + GetWindowLongPtrW(window, GWLP_USERDATA)); + if (message == WM_NCCREATE) { + const auto* create = reinterpret_cast(lparam); + host = static_cast(create->lpCreateParams); + SetWindowLongPtrW(window, GWLP_USERDATA, + reinterpret_cast(host)); + } + + if (!host) + return DefWindowProcW(window, message, wparam, lparam); + + switch (message) { + case kWakeupMessage: + if (!host->closing.load(std::memory_order_acquire) && host->app) + ghostty_app_tick(host->app); + return 0; + case WM_SIZE: + case WM_DPICHANGED_AFTERPARENT: + UpdateSurfaceMetrics(host); + if (host->surface) + ghostty_surface_refresh(host->surface); + return 0; + case WM_SHOWWINDOW: + if (host->surface) + ghostty_surface_set_occlusion(host->surface, wparam != 0); + return 0; + case WM_SETFOCUS: + if (host->app) + ghostty_app_set_focus(host->app, true); + if (host->surface) + ghostty_surface_set_focus(host->surface, true); + return 0; + case WM_KILLFOCUS: + host->modifier_latch = GHOSTTY_MODS_NONE; + if (host->surface) + ghostty_surface_set_focus(host->surface, false); + if (host->app) + ghostty_app_set_focus(host->app, false); + return 0; + case WM_LBUTTONDOWN: + SetFocus(window); + SetCapture(window); + host->left_captured = true; + SendMousePosition(host, lparam); + if (host->surface) + ghostty_surface_mouse_button(host->surface, GHOSTTY_MOUSE_PRESS, + GHOSTTY_MOUSE_LEFT, + CurrentModifiers(host)); + return 0; + case WM_LBUTTONUP: + SendMousePosition(host, lparam); + if (host->surface) + ghostty_surface_mouse_button(host->surface, GHOSTTY_MOUSE_RELEASE, + GHOSTTY_MOUSE_LEFT, + CurrentModifiers(host)); + if (host->left_captured) { + ReleaseCapture(); + host->left_captured = false; + } + return 0; + case WM_RBUTTONDOWN: + SetFocus(window); + SendMousePosition(host, lparam); + host->right_captured = + host->surface && ghostty_surface_mouse_captured(host->surface); + if (host->right_captured) { + SetCapture(window); + ghostty_surface_mouse_button(host->surface, GHOSTTY_MOUSE_PRESS, + GHOSTTY_MOUSE_RIGHT, + CurrentModifiers(host)); + } else { + ShowContextMenu(host, lparam); + } + return 0; + case WM_RBUTTONUP: + if (host->right_captured && host->surface) { + SendMousePosition(host, lparam); + ghostty_surface_mouse_button(host->surface, GHOSTTY_MOUSE_RELEASE, + GHOSTTY_MOUSE_RIGHT, + CurrentModifiers(host)); + ReleaseCapture(); + host->right_captured = false; + } + return 0; + case WM_MOUSEMOVE: + SendMousePosition(host, lparam); + return 0; + case WM_MOUSEWHEEL: + case WM_MOUSEHWHEEL: + if (host->surface) { + const double delta = + static_cast(GET_WHEEL_DELTA_WPARAM(wparam)) / WHEEL_DELTA; + ghostty_surface_mouse_scroll( + host->surface, message == WM_MOUSEHWHEEL ? delta : 0.0, + message == WM_MOUSEWHEEL ? delta : 0.0, 0); + } + return 0; + case WM_KEYDOWN: + case WM_SYSKEYDOWN: + // Alt+Tab belongs to Windows. Do not leak the Tab press into the shell + // if the system sends the child HWND a system-key message first. + if (message == WM_SYSKEYDOWN && wparam == VK_TAB) + return 0; + UpdateModifierLatch(host, wparam, lparam, true); + // Windows delivers committed printable text through WM_CHAR. Sending + // the physical key as well would duplicate unshifted digits for OEM + // punctuation (for example '%' becoming '5'). Command chords still go + // through Ghostty's key binding path. + if (IsTextProducingKey(wparam) && !HasCommandModifier(host)) + return 0; + SendKey(host, (lparam & (1LL << 30)) ? GHOSTTY_ACTION_REPEAT + : GHOSTTY_ACTION_PRESS, + wparam, lparam); + return 0; + case WM_KEYUP: + case WM_SYSKEYUP: + if (message == WM_SYSKEYUP && wparam == VK_TAB) + return 0; + if (IsTextProducingKey(wparam) && !HasCommandModifier(host)) { + UpdateModifierLatch(host, wparam, lparam, false); + return 0; + } + SendKey(host, GHOSTTY_ACTION_RELEASE, wparam, lparam); + UpdateModifierLatch(host, wparam, lparam, false); + return 0; + case WM_CHAR: + if (wparam >= 0x20 && wparam != 0x7f) + SendUtf16Character(host, static_cast(wparam)); + return 0; + case WM_UNICHAR: + if (wparam == UNICODE_NOCHAR) + return TRUE; + if (wparam <= 0xffff) { + SendUtf16Character(host, static_cast(wparam)); + } else if (wparam <= 0x10ffff) { + const uint32_t codepoint = static_cast(wparam) - 0x10000; + SendUtf16Character(host, + static_cast(0xd800 + (codepoint >> 10))); + SendUtf16Character(host, + static_cast(0xdc00 + (codepoint & 0x3ff))); + } + return 0; + case WM_SYSCHAR: + return 0; + case WM_SETCURSOR: + SetCursor(LoadCursorW(nullptr, MAKEINTRESOURCEW(32513))); + return TRUE; + case WM_ERASEBKGND: + return 1; + case WM_PAINT: { + PAINTSTRUCT paint = {}; + BeginPaint(window, &paint); + EndPaint(window, &paint); + if (host->surface) + ghostty_surface_refresh(host->surface); + return 0; + } + case WM_NCDESTROY: + SetWindowLongPtrW(window, GWLP_USERDATA, 0); + return DefWindowProcW(window, message, wparam, lparam); + default: + return DefWindowProcW(window, message, wparam, lparam); + } +} + +bool EnsureWindowClass() { + static std::once_flag once; + static bool success = false; + std::call_once(once, [] { + WNDCLASSEXW window_class = {}; + window_class.cbSize = sizeof(window_class); + window_class.style = CS_OWNDC | CS_HREDRAW | CS_VREDRAW; + window_class.lpfnWndProc = TerminalWindowProc; + window_class.hInstance = GetModuleHandleW(nullptr); + window_class.hCursor = LoadCursorW(nullptr, MAKEINTRESOURCEW(32513)); + window_class.hbrBackground = + static_cast(GetStockObject(BLACK_BRUSH)); + window_class.lpszClassName = kTerminalWindowClass; + success = RegisterClassExW(&window_class) != 0 || + GetLastError() == ERROR_CLASS_ALREADY_EXISTS; + }); + return success; +} + +void DestroyHostResources(GhosttyHost* host) { + if (!host || host->closing.exchange(true, std::memory_order_acq_rel)) + return; + Trace("destroy: entered"); + if (host->surface) { + ghostty_surface_set_focus(host->surface, false); + Trace("destroy: freeing surface"); + ghostty_surface_free(host->surface); + Trace("destroy: surface freed"); + host->surface = nullptr; + } + if (host->app) { + Trace("destroy: freeing app"); + ghostty_app_free(host->app); + Trace("destroy: app freed"); + host->app = nullptr; + } + if (host->config) { + Trace("destroy: freeing config"); + ghostty_config_free(host->config); + Trace("destroy: config freed"); + host->config = nullptr; + } + Trace("destroy: freeing WGL"); + DestroyWgl(host); + Trace("destroy: WGL freed"); + if (host->child) { + SetWindowLongPtrW(host->child, GWLP_USERDATA, 0); + DestroyWindow(host->child); + host->child = nullptr; + } + host->parent = nullptr; + Trace("destroy: complete"); +} + +void FinalizeHost(napi_env, void* data, void*) { + auto* host = static_cast(data); + DestroyHostResources(host); + delete host; +} + +napi_value Create(napi_env env, napi_callback_info info) { + Trace("create: entered"); + size_t argc = 2; + napi_value args[2]; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || + argc != 2) { + Throw(env, "create expects a native window handle and bounds/options"); + return nullptr; + } + + void* handle_data = nullptr; + size_t handle_size = 0; + if (napi_get_buffer_info(env, args[0], &handle_data, &handle_size) != + napi_ok || + handle_size != sizeof(HWND)) { + Throw(env, + "Expected BrowserWindow.getNativeWindowHandle() on 64-bit Windows"); + return nullptr; + } + HWND parent = *static_cast(handle_data); + if (!parent || !IsWindow(parent)) { + Throw(env, "Electron returned an invalid parent HWND"); + return nullptr; + } + + double x = 0; + double y = 0; + double width = 800; + double height = 600; + if (!GetNamedDouble(env, args[1], "x", &x) || + !GetNamedDouble(env, args[1], "y", &y) || + !GetNamedDouble(env, args[1], "width", &width) || + !GetNamedDouble(env, args[1], "height", &height)) { + Throw(env, "Bounds must contain numeric x, y, width, and height"); + return nullptr; + } + + if (!EnsureWindowClass() || !EnsureGhosttyInitialized()) { + Throw(env, "Unable to initialize the Ghostty Windows host"); + return nullptr; + } + Trace("create: libghostty initialized"); + + Trace("create: allocating host"); + auto* host = new GhosttyHost(); + Trace("create: host allocated"); + InitializeCriticalSection(&host->context_lock); + host->context_lock_initialized = true; + host->parent = parent; + Trace("create: creating child HWND"); + host->child = CreateWindowExW( + 0, kTerminalWindowClass, L"Native libghostty terminal", + WS_CHILD | WS_VISIBLE | WS_CLIPSIBLINGS | WS_CLIPCHILDREN, + DipToPixel(parent, x), DipToPixel(parent, y), + DipToPixel(parent, width), DipToPixel(parent, height), parent, nullptr, + GetModuleHandleW(nullptr), host); + if (!host->child) { + DestroyHostResources(host); + delete host; + Throw(env, "CreateWindowEx failed for the terminal child HWND"); + return nullptr; + } + Trace("create: child HWND created"); + + std::string wgl_error; + if (!InitializeWgl(host, &wgl_error)) { + DestroyHostResources(host); + delete host; + Throw(env, wgl_error.c_str()); + return nullptr; + } + Trace("create: WGL context created"); + + host->config = ghostty_config_new(); + if (!host->config) { + DestroyHostResources(host); + delete host; + Throw(env, "ghostty_config_new failed"); + return nullptr; + } + ghostty_config_load_default_files(host->config); + ghostty_config_finalize(host->config); + Trace("create: config finalized"); + + ghostty_runtime_config_s runtime = {}; + runtime.userdata = host; + runtime.supports_selection_clipboard = false; + runtime.wakeup_cb = Wakeup; + runtime.action_cb = Action; + runtime.read_clipboard_cb = ReadClipboard; + runtime.confirm_read_clipboard_cb = ConfirmReadClipboard; + runtime.write_clipboard_cb = WriteClipboard; + runtime.close_surface_cb = CloseSurface; + host->app = ghostty_app_new(&runtime, host->config); + if (!host->app) { + DestroyHostResources(host); + delete host; + Throw(env, "ghostty_app_new failed"); + return nullptr; + } + Trace("create: app created"); + + const std::string working_directory = + GetNamedString(env, args[1], "workingDirectory"); + const std::string command = GetNamedString(env, args[1], "command"); + ghostty_surface_config_s surface = ghostty_surface_config_new(); + surface.platform_tag = GHOSTTY_PLATFORM_OPENGL; + surface.platform.opengl.userdata = host; + surface.platform.opengl.make_current = WglMakeCurrent; + surface.platform.opengl.clear_current = WglClearCurrent; + surface.platform.opengl.get_proc_address = WglGetProcAddress; + surface.platform.opengl.swap_buffers = WglSwapBuffers; + surface.userdata = host; + const UINT dpi = GetDpiForWindow(host->child); + surface.scale_factor = dpi > 0 ? static_cast(dpi) / 96.0 : 1.0; + surface.working_directory = + working_directory.empty() ? nullptr : working_directory.c_str(); + surface.command = command.empty() ? nullptr : command.c_str(); + host->surface = ghostty_surface_new(host->app, &surface); + if (!host->surface) { + DestroyHostResources(host); + delete host; + Throw(env, "ghostty_surface_new failed for the WGL surface"); + return nullptr; + } + Trace("create: surface created"); + + host->closing.store(false, std::memory_order_release); + ghostty_app_set_focus(host->app, true); + ghostty_surface_set_focus(host->surface, true); + UpdateSurfaceMetrics(host); + SetWindowPos(host->child, HWND_TOP, DipToPixel(parent, x), + DipToPixel(parent, y), DipToPixel(parent, width), + DipToPixel(parent, height), + SWP_SHOWWINDOW | SWP_NOACTIVATE); + + napi_value external; + napi_create_external(env, host, FinalizeHost, nullptr, &external); + Trace("create: complete"); + return external; +} + +GhosttyHost* GetHost(napi_env env, napi_value value) { + GhosttyHost* host = nullptr; + if (napi_get_value_external(env, value, reinterpret_cast(&host)) != + napi_ok || + !host) { + return nullptr; + } + return host; +} + +napi_value SetBounds(napi_env env, napi_callback_info info) { + size_t argc = 2; + napi_value args[2]; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || + argc != 2) { + Throw(env, "setBounds expects a terminal handle and bounds"); + return nullptr; + } + GhosttyHost* host = GetHost(env, args[0]); + if (!host || host->closing.load(std::memory_order_acquire) || !host->child) { + Throw(env, "Invalid terminal handle"); + return nullptr; + } + double x = 0; + double y = 0; + double width = 0; + double height = 0; + if (!GetNamedDouble(env, args[1], "x", &x) || + !GetNamedDouble(env, args[1], "y", &y) || + !GetNamedDouble(env, args[1], "width", &width) || + !GetNamedDouble(env, args[1], "height", &height)) { + Throw(env, "Bounds must contain numeric x, y, width, and height"); + return nullptr; + } + SetWindowPos(host->child, HWND_TOP, DipToPixel(host->parent, x), + DipToPixel(host->parent, y), DipToPixel(host->parent, width), + DipToPixel(host->parent, height), + SWP_SHOWWINDOW | SWP_NOACTIVATE); + UpdateSurfaceMetrics(host); + napi_value undefined; + napi_get_undefined(env, &undefined); + return undefined; +} + +napi_value SendText(napi_env env, napi_callback_info info) { + size_t argc = 2; + napi_value args[2]; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || + argc != 2) { + Throw(env, "sendText expects a terminal handle and UTF-8 text"); + return nullptr; + } + GhosttyHost* host = GetHost(env, args[0]); + if (!host || host->closing.load(std::memory_order_acquire) || + !host->surface) { + Throw(env, "Invalid terminal handle"); + return nullptr; + } + size_t length = 0; + if (napi_get_value_string_utf8(env, args[1], nullptr, 0, &length) != + napi_ok) { + Throw(env, "sendText text must be a string"); + return nullptr; + } + std::string text(length + 1, '\0'); + if (napi_get_value_string_utf8(env, args[1], text.data(), text.size(), + &length) != napi_ok) { + Throw(env, "Unable to read sendText text"); + return nullptr; + } + text.resize(length); + ghostty_surface_text_input(host->surface, text.data(), text.size()); + napi_value undefined; + napi_get_undefined(env, &undefined); + return undefined; +} + +napi_value Focus(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || + argc != 1) { + Throw(env, "focus expects a terminal handle"); + return nullptr; + } + GhosttyHost* host = GetHost(env, args[0]); + if (!host || host->closing.load(std::memory_order_acquire) || !host->child) { + Throw(env, "Invalid terminal handle"); + return nullptr; + } + SetFocus(host->child); + napi_value undefined; + napi_get_undefined(env, &undefined); + return undefined; +} + +void SetNamedString(napi_env env, + napi_value object, + const char* name, + const std::string& value) { + napi_value result; + napi_create_string_utf8(env, value.c_str(), value.size(), &result); + napi_set_named_property(env, object, name, result); +} + +void SetNamedBool(napi_env env, + napi_value object, + const char* name, + bool value) { + napi_value result; + napi_get_boolean(env, value, &result); + napi_set_named_property(env, object, name, result); +} + +napi_value Diagnostics(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || + argc != 1) { + Throw(env, "diagnostics expects a terminal handle"); + return nullptr; + } + GhosttyHost* host = GetHost(env, args[0]); + if (!host) { + Throw(env, "Invalid terminal handle"); + return nullptr; + } + napi_value result; + napi_create_object(env, &result); + SetNamedBool(env, result, "realLibghostty", host->surface != nullptr); + SetNamedBool(env, result, "rendererHealthy", + host->renderer_healthy.load(std::memory_order_acquire)); + SetNamedString(env, result, "renderer", "libghostty/OpenGL/WGL"); + SetNamedString(env, result, "glVersion", host->gl_version); + napi_value swaps; + napi_create_bigint_uint64(env, host->swaps.load(std::memory_order_relaxed), + &swaps); + napi_set_named_property(env, result, "swaps", swaps); + return result; +} + +napi_value Destroy(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value args[1]; + if (napi_get_cb_info(env, info, &argc, args, nullptr, nullptr) != napi_ok || + argc != 1) { + Throw(env, "destroy expects a terminal handle"); + return nullptr; + } + GhosttyHost* host = GetHost(env, args[0]); + if (!host) { + Throw(env, "Invalid terminal handle"); + return nullptr; + } + DestroyHostResources(host); + napi_value undefined; + napi_get_undefined(env, &undefined); + return undefined; +} + +napi_value Init(napi_env env, napi_value exports) { + napi_property_descriptor properties[] = { + {"create", nullptr, Create, nullptr, nullptr, nullptr, napi_default, + nullptr}, + {"setBounds", nullptr, SetBounds, nullptr, nullptr, nullptr, + napi_default, nullptr}, + {"sendText", nullptr, SendText, nullptr, nullptr, nullptr, napi_default, + nullptr}, + {"focus", nullptr, Focus, nullptr, nullptr, nullptr, napi_default, + nullptr}, + {"diagnostics", nullptr, Diagnostics, nullptr, nullptr, nullptr, + napi_default, nullptr}, + {"destroy", nullptr, Destroy, nullptr, nullptr, nullptr, napi_default, + nullptr}, + }; + napi_define_properties(env, exports, std::size(properties), properties); + return exports; +} + +} // namespace + +NAPI_MODULE(NODE_GYP_MODULE_NAME, Init) diff --git a/pkg/opengl/glad.zig b/pkg/opengl/glad.zig index 663e75e12f9..90d98988094 100644 --- a/pkg/opengl/glad.zig +++ b/pkg/opengl/glad.zig @@ -14,12 +14,18 @@ pub threadlocal var context: Context = undefined; /// forms of the function depending on what we're interfacing with. pub fn load(getProcAddress: anytype) !c_int { const GlProc = *const fn () callconv(.c) void; + const GlfwFnValue = fn ([*:0]const u8) callconv(.c) ?GlProc; const GlfwFn = *const fn ([*:0]const u8) callconv(.c) ?GlProc; const res = switch (@TypeOf(getProcAddress)) { // glfw GlfwFn => c.gladLoadGLContext(&context, @ptrCast(getProcAddress)), + // A bare function declaration needs its address taken before it can + // be passed through C's function-pointer ABI. This is the form used + // by the embedded renderer's callback trampoline. + GlfwFnValue => c.gladLoadGLContext(&context, @ptrCast(&getProcAddress)), + // null proc address means that we are just loading the globally // pointed gl functions @TypeOf(null) => c.gladLoaderLoadGLContext(&context), diff --git a/src/Surface.zig b/src/Surface.zig index 71933e9b377..d002bc44b9f 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -764,6 +764,12 @@ pub fn init( ); self.renderer_thr.setName("renderer") catch {}; + // libghostty allows the embedder to free a surface as soon as creation + // returns. Wait until the renderer's stop watcher is armed so that teardown + // cannot race thread startup and lose the stop notification. + if (comptime apprt.runtime == apprt.embedded) + self.renderer_thread.started.wait(); + // Start our IO thread self.io_thr = try std.Thread.spawn( .{}, @@ -772,6 +778,9 @@ pub fn init( ); self.io_thr.setName("io") catch {}; + if (comptime apprt.runtime == apprt.embedded) + self.io_thread.started.wait(); + // Determine our initial window size if configured. We need to do this // quite late in the process because our height/width are in grid dimensions, // so we need to know our cell sizes first. diff --git a/src/build/GhosttyLib.zig b/src/build/GhosttyLib.zig index faa7216b56d..d90de1b726b 100644 --- a/src/build/GhosttyLib.zig +++ b/src/build/GhosttyLib.zig @@ -12,6 +12,7 @@ step: *std.Build.Step, /// The final static library file output: std.Build.LazyPath, +implib: ?std.Build.LazyPath, dsym: ?std.Build.LazyPath, pkg_config: ?std.Build.LazyPath, pkg_config_static: ?std.Build.LazyPath, @@ -60,6 +61,7 @@ pub fn initStatic( return .{ .step = combined.step, .output = combined.output, + .implib = null, // Static libraries cannot have dSYMs because they aren't linked. .dsym = null, @@ -73,6 +75,9 @@ pub fn initShared( deps: *const SharedDeps, ) !GhosttyLib { const lib = b.addLibrary(.{ + // Keep the emitted basename identical to the installed embedder + // library on every platform. Windows import libraries record this + // name, and Linux derives the shared-object SONAME from it. .name = "ghostty-internal", .linkage = .dynamic, .root_module = b.createModule(.{ @@ -152,6 +157,10 @@ pub fn initShared( return .{ .step = &lib.step, .output = lib.getEmittedBin(), + .implib = if (deps.config.target.result.os.tag == .windows) + lib.getEmittedImplib() + else + null, .dsym = dsymutil, .pkg_config = pcs.shared, .pkg_config_static = pcs.static, @@ -181,6 +190,7 @@ pub fn initMacOSUniversal( return .{ .step = universal.step, .output = universal.output, + .implib = null, // You can't run dsymutil on a universal binary, you have to // do it on the individual binaries. @@ -196,6 +206,17 @@ pub fn install(self: *const GhosttyLib, name: []const u8) void { const lib_install = b.addInstallLibFile(self.output, name); step.dependOn(&lib_install.step); + // A Windows DLL is not directly linkable by MSVC consumers. Zig emits + // the matching COFF import library, so install it beside the DLL instead + // of forcing every embedder to find an unstable cache path. + if (self.implib) |implib| { + const implib_install = b.addInstallLibFile( + implib, + "ghostty-internal.lib", + ); + step.dependOn(&implib_install.step); + } + if (self.pkg_config) |pc| { step.dependOn(&b.addInstallFileWithDir( pc, diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index a6816ae8b9c..85afd6ddb22 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -281,8 +281,9 @@ pub fn displayRealized(self: *const OpenGL) void { }, // Embedded contexts are prepared by surfaceInit and threadEnter. The - // generic renderer still references this hook, so it must compile as a - // no-op even though an embedder never calls it. + // embedder owns one context for the surface lifetime and never enters + // GTK's realize cycle, but the generic renderer still instantiates + // this method for every OpenGL runtime. apprt.embedded => {}, else => @compileError("unsupported app runtime for OpenGL"), diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index 8d3abc2633c..3667f110e93 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -52,6 +52,11 @@ wakeup_c: xev.Completion = .{}, stop: xev.Async, stop_c: xev.Completion = .{}, +/// Set after the stop watcher is armed. Embedded callers can destroy a +/// surface immediately after creation, so Surface.init must not return while +/// a stop notification could still be lost during thread startup. +started: std.Thread.ResetEvent = .{}, + /// The timer used for rendering render_h: xev.Timer, render_c: xev.Completion = .{}, @@ -331,6 +336,11 @@ fn threadMain_(self: *Thread) !void { // Setup our thread QoS self.setQosClass(); + // Arm stop before any fallible renderer setup. Surface.init waits for + // this signal in embedded builds, making an immediate free deterministic. + self.stop.wait(&self.loop, &self.stop_c, Thread, self, stopCallback); + self.started.set(); + // Run our loop start/end callbacks if the renderer cares. const has_loop = @hasDecl(rendererpkg.Renderer, "loopEnter"); if (has_loop) try self.renderer.loopEnter(self); @@ -344,7 +354,6 @@ fn threadMain_(self: *Thread) !void { // Start the async handlers self.wakeup.wait(&self.loop, &self.wakeup_c, Thread, self, wakeupCallback); - self.stop.wait(&self.loop, &self.stop_c, Thread, self, stopCallback); self.draw_now.wait(&self.loop, &self.draw_now_c, Thread, self, drawNowCallback); // Send an initial wakeup message so that we render right away. diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index ce4c1f4af84..4a811d80234 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -55,6 +55,9 @@ wakeup_c: xev.Completion = .{}, stop: xev.Async, stop_c: xev.Completion = .{}, +/// Set after the stop watcher is armed. See renderer.Thread.started. +started: std.Thread.ResetEvent = .{}, + /// This is used for timer-based selection scrolling. scroll: xev.Timer, scroll_c: xev.Completion = .{}, @@ -260,6 +263,11 @@ fn threadMain_(self: *Thread, io: *termio.Termio) !void { // ourselves and the thread data so we can thread that through (pun intended). var cb: CallbackData = .{ .self = self, .io = io }; + // Arm stop before fallible backend setup so an immediate surface free + // cannot lose its notification and strand Surface.deinit in join(). + self.stop.wait(&self.loop, &self.stop_c, CallbackData, &cb, stopCallback); + self.started.set(); + // Run our thread start/end callbacks. This allows the implementation // to hook into the event loop as needed. The thread data is created // on the stack here so that it has a stable pointer throughout the @@ -270,7 +278,6 @@ fn threadMain_(self: *Thread, io: *termio.Termio) !void { // Start the async handlers. mailbox.wakeup.wait(&self.loop, &self.wakeup_c, CallbackData, &cb, wakeupCallback); - self.stop.wait(&self.loop, &self.stop_c, CallbackData, &cb, stopCallback); // Run log.debug("starting IO thread", .{}); From 437064019e0a0b766f1751d3ff6ad7cf886a9fdf Mon Sep 17 00:00:00 2001 From: cmux-lawrence Date: Mon, 13 Jul 2026 01:31:58 -0700 Subject: [PATCH 04/11] fix: harden embedded OpenGL resize and teardown --- pkg/opengl/glad.zig | 7 +++++++ src/renderer/OpenGL.zig | 17 ++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/pkg/opengl/glad.zig b/pkg/opengl/glad.zig index 90d98988094..6ad248ed110 100644 --- a/pkg/opengl/glad.zig +++ b/pkg/opengl/glad.zig @@ -17,6 +17,13 @@ pub fn load(getProcAddress: anytype) !c_int { const GlfwFnValue = fn ([*:0]const u8) callconv(.c) ?GlProc; const GlfwFn = *const fn ([*:0]const u8) callconv(.c) ?GlProc; + // gladLoadGLContext only fills the function table. It does not initialize + // glad_loader_handle, which is consumed by gladLoaderUnloadGLContext. + // Embedded surfaces can move one GL context across threads and reload this + // thread-local value during teardown, so carrying Zig's undefined-memory + // poison into that field would make unload attempt to close a bogus handle. + context = std.mem.zeroes(Context); + const res = switch (@TypeOf(getProcAddress)) { // glfw GlfwFn => c.gladLoadGLContext(&context, @ptrCast(getProcAddress)), diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 85afd6ddb22..f166025945b 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -292,9 +292,24 @@ pub fn displayRealized(self: *const OpenGL) void { /// Actions taken before doing anything in `drawFrame`. /// -/// Right now there's nothing we need to do for OpenGL. +/// Embedded hosts own the default framebuffer and can resize it independently +/// of the long-lived renderer context. OpenGL does not update the viewport +/// when a drawable changes size, so synchronize it before every frame. pub fn drawFrameStart(self: *OpenGL) void { _ = self; + if (comptime is_embedded) { + const state = embedded_state orelse return; + const size = state.surface.getSize() catch |err| { + log.err("error querying embedded OpenGL surface size err={}", .{err}); + return; + }; + gl.glad.context.Viewport.?( + 0, + 0, + @intCast(size.width), + @intCast(size.height), + ); + } } /// Actions taken after `drawFrame` is done. From c4d0a1e9720197339fd7cf199e296dd83bd86ff8 Mon Sep 17 00:00:00 2001 From: cmux-lawrence Date: Mon, 13 Jul 2026 04:14:39 -0700 Subject: [PATCH 05/11] build: preserve CRLF for Windows launchers --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index 6ab2e8bf481..9851427dced 100644 --- a/.gitattributes +++ b/.gitattributes @@ -29,6 +29,7 @@ Makefile text eol=lf *.txt text eol=lf # Windows resource files - preserve as-is (native Windows tooling) +*.cmd text eol=crlf *.rc -text *.manifest -text From 559d8d88bbe1987aa55d6b4245ad721cab9c4234 Mon Sep 17 00:00:00 2001 From: cmux-lawrence Date: Mon, 13 Jul 2026 11:11:07 -0700 Subject: [PATCH 06/11] fix(windows): use GDI pixel format API for native WGL --- .../src/libghostty_embed_win.cc | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/example/electron-embed-windows/src/libghostty_embed_win.cc b/example/electron-embed-windows/src/libghostty_embed_win.cc index 68177e4d7cf..d5b6a462834 100644 --- a/example/electron-embed-windows/src/libghostty_embed_win.cc +++ b/example/electron-embed-windows/src/libghostty_embed_win.cc @@ -94,6 +94,7 @@ struct GhosttyHost { wchar_t pending_high_surrogate = 0; int modifier_latch = GHOSTTY_MODS_NONE; std::string gl_version; + std::string pixel_format_api; }; void Throw(napi_env env, const char* message) { @@ -331,7 +332,9 @@ bool LoadWglApi(GhosttyHost* host, std::string* error) { wchar_t override_path[MAX_PATH] = {}; const DWORD override_length = GetEnvironmentVariableW( L"GHOSTTY_MESA_OPENGL_PATH", override_path, std::size(override_path)); - if (override_length > 0 && override_length < std::size(override_path)) { + const bool has_opengl_override = + override_length > 0 && override_length < std::size(override_path); + if (has_opengl_override) { host->opengl_module = LoadLibraryExW( override_path, nullptr, LOAD_WITH_ALTERED_SEARCH_PATH); } else { @@ -347,8 +350,6 @@ bool LoadWglApi(GhosttyHost* host, std::string* error) { }; host->wgl_create_context = reinterpret_cast(load("wglCreateContext")); - host->wgl_choose_pixel_format = reinterpret_cast( - load("wglChoosePixelFormat")); host->wgl_delete_context = reinterpret_cast(load("wglDeleteContext")); host->wgl_get_current_context = reinterpret_cast( @@ -357,12 +358,27 @@ bool LoadWglApi(GhosttyHost* host, std::string* error) { reinterpret_cast(load("wglGetProcAddress")); host->wgl_make_current = reinterpret_cast(load("wglMakeCurrent")); - host->wgl_set_pixel_format = reinterpret_cast( - load("wglSetPixelFormat")); - host->wgl_swap_buffers = - reinterpret_cast(load("wglSwapBuffers")); host->gl_get_string = reinterpret_cast(load("glGetString")); + + // Mesa's drop-in OpenGL DLL owns its pixel-format and swap entrypoints. + // The Windows system OpenGL path instead requires the public GDI APIs. + // opengl32.dll exports similarly named WGL helpers, but its + // wglSetPixelFormat can report success without setting the HDC format. + if (has_opengl_override) { + host->wgl_choose_pixel_format = reinterpret_cast( + load("wglChoosePixelFormat")); + host->wgl_set_pixel_format = reinterpret_cast( + load("wglSetPixelFormat")); + host->wgl_swap_buffers = + reinterpret_cast(load("wglSwapBuffers")); + host->pixel_format_api = "OpenGL override DLL"; + } else { + host->wgl_choose_pixel_format = &::ChoosePixelFormat; + host->wgl_set_pixel_format = &::SetPixelFormat; + host->wgl_swap_buffers = &::SwapBuffers; + host->pixel_format_api = "GDI32"; + } if (!host->wgl_choose_pixel_format || !host->wgl_create_context || !host->wgl_delete_context || !host->wgl_get_current_context || !host->wgl_get_proc_address || @@ -1195,6 +1211,7 @@ napi_value Diagnostics(napi_env env, napi_callback_info info) { host->renderer_healthy.load(std::memory_order_acquire)); SetNamedString(env, result, "renderer", "libghostty/OpenGL/WGL"); SetNamedString(env, result, "glVersion", host->gl_version); + SetNamedString(env, result, "pixelFormatApi", host->pixel_format_api); napi_value swaps; napi_create_bigint_uint64(env, host->swaps.load(std::memory_order_relaxed), &swaps); From 65b62431f02c3078552fda7ddbbc2f01e4bce58d Mon Sep 17 00:00:00 2001 From: cmux-lawrence Date: Mon, 13 Jul 2026 11:42:55 -0700 Subject: [PATCH 07/11] test(windows): add focused ConPTY teardown stress --- example/electron-embed-windows/README.md | 5 +++++ example/electron-embed-windows/package.json | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/example/electron-embed-windows/README.md b/example/electron-embed-windows/README.md index 694e3b95294..b6342bf1913 100644 --- a/example/electron-embed-windows/README.md +++ b/example/electron-embed-windows/README.md @@ -71,3 +71,8 @@ WGL frame, or retained memory growth above the limit. `npm run stress:cycles` repeats the test in five fresh Electron processes for cold-start coverage. The aggregate report is `artifacts\stress-cycles.json`. + +`npm run stress:teardown` is the focused ConPTY reader teardown regression +harness. It creates and immediately frees 1,000 native surfaces in one +Electron process and fails if any `ghostty_surface_free` call blocks long +enough to hit the process watchdog. diff --git a/example/electron-embed-windows/package.json b/example/electron-embed-windows/package.json index d2d0424e16b..d9f6046da83 100644 --- a/example/electron-embed-windows/package.json +++ b/example/electron-embed-windows/package.json @@ -12,7 +12,8 @@ "deploy:mesa": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/deploy-mesa.ps1", "start": "electron .", "stress": "electron . --stress", - "stress:cycles": "node scripts/stress-cycles.mjs" + "stress:cycles": "node scripts/stress-cycles.mjs", + "stress:teardown": "node scripts/stress-cycles.mjs --cycles=1 --iterations=0 --resizes=0 --renderer-deaths=0 --immediate-destroys=1000 --timeout-ms=240000" }, "devDependencies": { "electron": "43.1.0", From 3e0d455f7fe88d03f2c86cd28f38cd0c5f5a9f29 Mon Sep 17 00:00:00 2001 From: cmux-lawrence Date: Mon, 13 Jul 2026 11:55:42 -0700 Subject: [PATCH 08/11] fix(windows): cancel ConPTY output reads reliably --- src/os/windows.zig | 8 +++ src/pty.zig | 95 +++++++++++++++++++++----------- src/termio/Exec.zig | 130 +++++++++++++++++++++++++++++++++++--------- 3 files changed, 174 insertions(+), 59 deletions(-) diff --git a/src/os/windows.zig b/src/os/windows.zig index 6e452fb7339..5cef8aa5495 100644 --- a/src/os/windows.zig +++ b/src/os/windows.zig @@ -14,12 +14,15 @@ pub const FILE_ATTRIBUTE_NORMAL = windows.FILE_ATTRIBUTE_NORMAL; pub const FILE_FLAG_OVERLAPPED = windows.FILE_FLAG_OVERLAPPED; pub const FILE_SHARE_READ = windows.FILE_SHARE_READ; pub const GENERIC_READ = windows.GENERIC_READ; +pub const GENERIC_WRITE = windows.GENERIC_WRITE; pub const HANDLE = windows.HANDLE; pub const HANDLE_FLAG_INHERIT = windows.HANDLE_FLAG_INHERIT; pub const INFINITE = windows.INFINITE; pub const INVALID_HANDLE_VALUE = windows.INVALID_HANDLE_VALUE; pub const MAX_PATH = windows.MAX_PATH; pub const OPEN_EXISTING = windows.OPEN_EXISTING; +pub const OVERLAPPED = windows.OVERLAPPED; +pub const PIPE_ACCESS_INBOUND = windows.PIPE_ACCESS_INBOUND; pub const PIPE_ACCESS_OUTBOUND = windows.PIPE_ACCESS_OUTBOUND; pub const PIPE_TYPE_BYTE = windows.PIPE_TYPE_BYTE; pub const PROCESS_INFORMATION = windows.PROCESS_INFORMATION; @@ -29,6 +32,8 @@ pub const STARTUPINFOW = windows.STARTUPINFOW; pub const STARTF_USESTDHANDLES = windows.STARTF_USESTDHANDLES; pub const SYNCHRONIZE = windows.SYNCHRONIZE; pub const WAIT_FAILED = windows.WAIT_FAILED; +pub const CREATE_EVENT_MANUAL_RESET = windows.CREATE_EVENT_MANUAL_RESET; +pub const EVENT_ALL_ACCESS = windows.EVENT_ALL_ACCESS; pub const FALSE = windows.FALSE; pub const TRUE = windows.TRUE; @@ -87,6 +92,9 @@ pub const exp = struct { lpTotalBytesAvail: ?*windows.DWORD, lpBytesLeftThisMessage: ?*windows.DWORD, ) callconv(.winapi) windows.BOOL; + pub extern "kernel32" fn ResetEvent( + hEvent: windows.HANDLE, + ) callconv(.winapi) windows.BOOL; // Duplicated here because lpCommandLine is not marked optional in zig std pub extern "kernel32" fn CreateProcessW( lpApplicationName: ?windows.LPWSTR, diff --git a/src/pty.zig b/src/pty.zig index 40277a8bacf..6976c474ec3 100644 --- a/src/pty.zig +++ b/src/pty.zig @@ -343,23 +343,43 @@ const WindowsPty = struct { pub fn open(size: winsize) OpenError!Pty { var pty: Pty = undefined; - var pipe_path_buf: [128]u8 = undefined; - var pipe_path_buf_w: [128]u16 = undefined; - const pipe_path = std.fmt.bufPrintZ( - &pipe_path_buf, - "\\\\.\\pipe\\LOCAL\\ghostty-pty-{d}-{d}", + const pipe_id = pipe_name_counter.fetchAdd(1, .monotonic); + + var in_pipe_path_buf: [128]u8 = undefined; + var in_pipe_path_buf_w: [128]u16 = undefined; + const in_pipe_path = std.fmt.bufPrintZ( + &in_pipe_path_buf, + "\\\\.\\pipe\\LOCAL\\ghostty-pty-{d}-{d}-in", + .{ + windows.GetCurrentProcessId(), + pipe_id, + }, + ) catch unreachable; + + const in_pipe_path_w_len = std.unicode.utf8ToUtf16Le( + &in_pipe_path_buf_w, + in_pipe_path, + ) catch unreachable; + in_pipe_path_buf_w[in_pipe_path_w_len] = 0; + const in_pipe_path_w = in_pipe_path_buf_w[0..in_pipe_path_w_len :0]; + + var out_pipe_path_buf: [128]u8 = undefined; + var out_pipe_path_buf_w: [128]u16 = undefined; + const out_pipe_path = std.fmt.bufPrintZ( + &out_pipe_path_buf, + "\\\\.\\pipe\\LOCAL\\ghostty-pty-{d}-{d}-out", .{ windows.GetCurrentProcessId(), - pipe_name_counter.fetchAdd(1, .monotonic), + pipe_id, }, ) catch unreachable; - const pipe_path_w_len = std.unicode.utf8ToUtf16Le( - &pipe_path_buf_w, - pipe_path, + const out_pipe_path_w_len = std.unicode.utf8ToUtf16Le( + &out_pipe_path_buf_w, + out_pipe_path, ) catch unreachable; - pipe_path_buf_w[pipe_path_w_len] = 0; - const pipe_path_w = pipe_path_buf_w[0..pipe_path_w_len :0]; + out_pipe_path_buf_w[out_pipe_path_w_len] = 0; + const out_pipe_path_w = out_pipe_path_buf_w[0..out_pipe_path_w_len :0]; const security_attributes = windows.SECURITY_ATTRIBUTES{ .nLength = @sizeOf(windows.SECURITY_ATTRIBUTES), @@ -368,7 +388,7 @@ const WindowsPty = struct { }; pty.in_pipe = windows.kernel32.CreateNamedPipeW( - pipe_path_w.ptr, + in_pipe_path_w.ptr, windows.PIPE_ACCESS_OUTBOUND | windows.exp.FILE_FLAG_FIRST_PIPE_INSTANCE | windows.FILE_FLAG_OVERLAPPED, @@ -386,7 +406,7 @@ const WindowsPty = struct { var security_attributes_read = security_attributes; pty.in_pipe_pty = windows.kernel32.CreateFileW( - pipe_path_w.ptr, + in_pipe_path_w.ptr, windows.GENERIC_READ, 0, &security_attributes_read, @@ -399,28 +419,39 @@ const WindowsPty = struct { } errdefer _ = windows.CloseHandle(pty.in_pipe_pty); - // The in_pipe needs to be created as a named pipe, since anonymous - // pipes created with CreatePipe do not support overlapped operations, - // and the IOCP backend of libxev only uses overlapped operations on files. - // - // It would be ideal to use CreatePipe here, so that our pipe isn't - // visible to any other processes. - - // if (windows.exp.kernel32.CreatePipe(&pty.in_pipe_pty, &pty.in_pipe, null, 0) == 0) { - // return windows.unexpectedError(windows.kernel32.GetLastError()); - // } - // errdefer { - // _ = windows.CloseHandle(pty.in_pipe_pty); - // _ = windows.CloseHandle(pty.in_pipe); - // } - - if (windows.exp.kernel32.CreatePipe(&pty.out_pipe, &pty.out_pipe_pty, null, 0) == 0) { + // Both app-side handles use overlapped I/O. ConPTY requires the client + // handles passed to CreatePseudoConsole to remain synchronous. + pty.out_pipe = windows.kernel32.CreateNamedPipeW( + out_pipe_path_w.ptr, + windows.PIPE_ACCESS_INBOUND | + windows.exp.FILE_FLAG_FIRST_PIPE_INSTANCE | + windows.FILE_FLAG_OVERLAPPED, + windows.PIPE_TYPE_BYTE, + 1, + 4096, + 4096, + 0, + &security_attributes, + ); + if (pty.out_pipe == windows.INVALID_HANDLE_VALUE) { return windows.unexpectedError(windows.kernel32.GetLastError()); } - errdefer { - _ = windows.CloseHandle(pty.out_pipe); - _ = windows.CloseHandle(pty.out_pipe_pty); + errdefer _ = windows.CloseHandle(pty.out_pipe); + + var security_attributes_write = security_attributes; + pty.out_pipe_pty = windows.kernel32.CreateFileW( + out_pipe_path_w.ptr, + windows.GENERIC_WRITE, + 0, + &security_attributes_write, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL, + null, + ); + if (pty.out_pipe_pty == windows.INVALID_HANDLE_VALUE) { + return windows.unexpectedError(windows.kernel32.GetLastError()); } + errdefer _ = windows.CloseHandle(pty.out_pipe_pty); try windows.SetHandleInformation(pty.in_pipe, windows.HANDLE_FLAG_INHERIT, 0); try windows.SetHandleInformation(pty.in_pipe_pty, windows.HANDLE_FLAG_INHERIT, 0); diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 0894f87b1b7..5dc82b76a1d 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -1762,44 +1762,120 @@ pub const ReadThread = struct { }; defer crash.sentry.thread_state = null; + const read_event = windows.kernel32.CreateEventExW( + null, + null, + windows.CREATE_EVENT_MANUAL_RESET, + windows.EVENT_ALL_ACCESS, + ) orelse { + log.err("error creating read event err={}", .{windows.kernel32.GetLastError()}); + return; + }; + defer _ = windows.CloseHandle(read_event); + var buf: [1024]u8 = undefined; while (true) { - while (true) { - var n: windows.DWORD = 0; - if (windows.kernel32.ReadFile(fd, &buf, buf.len, &n, null) == 0) { - const err = windows.kernel32.GetLastError(); - switch (err) { - // Check for a quit signal - .OPERATION_ABORTED => break, - - else => { - log.err("io reader error err={}", .{err}); - unreachable; - }, - } - } + // Checking both before and immediately after submission closes the + // race where teardown cancels before ReadFile becomes pending. + if (windowsQuitRequested(quit)) return; - @call(.always_inline, termio.Termio.processOutput, .{ io, buf[0..n] }); - - // See threadMainPosix: hand the renderer state mutex - // off if the renderer is waiting, since this loop - // would otherwise starve it under heavy output. - io.renderer_state.yieldToDemand(); + if (windows.exp.kernel32.ResetEvent(read_event) == 0) { + log.err("error resetting read event err={}", .{windows.kernel32.GetLastError()}); + return; } - var quit_bytes: windows.DWORD = 0; - if (windows.exp.kernel32.PeekNamedPipe(quit, null, 0, null, &quit_bytes, null) == 0) { + var overlapped = std.mem.zeroes(windows.OVERLAPPED); + overlapped.hEvent = read_event; + + if (windows.kernel32.ReadFile(fd, &buf, buf.len, null, &overlapped) == 0) { const err = windows.kernel32.GetLastError(); - log.err("quit pipe reader error err={}", .{err}); - unreachable; + switch (err) { + .IO_PENDING => {}, + .HANDLE_EOF, .BROKEN_PIPE, .NO_DATA => return, + .OPERATION_ABORTED => { + if (!windowsQuitRequested(quit)) { + log.err("io reader operation aborted without quit signal", .{}); + } + return; + }, + else => { + log.err("io reader submission error err={}", .{err}); + return; + }, + } } - if (quit_bytes > 0) { - log.info("read thread got quit signal", .{}); - return; + const quitting = windowsQuitRequested(quit); + if (quitting) { + // Cancel this exact request. The main IO thread also cancels + // all requests, but it may have done so before this ReadFile + // was submitted. + if (windows.kernel32.CancelIoEx(fd, &overlapped) == 0) { + switch (windows.kernel32.GetLastError()) { + .NOT_FOUND => {}, + else => |err| log.warn("error cancelling submitted read err={}", .{err}), + } + } } + + const n = windowsCompleteRead(fd, quit, &overlapped, quitting) orelse return; + if (quitting) return; + + @call(.always_inline, termio.Termio.processOutput, .{ io, buf[0..n] }); + + // See threadMainPosix: hand the renderer state mutex + // off if the renderer is waiting, since this loop + // would otherwise starve it under heavy output. + io.renderer_state.yieldToDemand(); } } + + /// Returns true when teardown requested the Windows reader to stop. A + /// broken quit pipe also stops the reader so teardown cannot deadlock. + fn windowsQuitRequested(quit: posix.fd_t) bool { + var quit_bytes: windows.DWORD = 0; + if (windows.exp.kernel32.PeekNamedPipe(quit, null, 0, null, &quit_bytes, null) == 0) { + log.err("quit pipe reader error err={}", .{windows.kernel32.GetLastError()}); + return true; + } + + if (quit_bytes == 0) return false; + log.info("read thread got quit signal", .{}); + return true; + } + + /// Waits until an overlapped read is fully complete before its buffer, + /// OVERLAPPED state, or event can be reused. Returns null for clean EOF, + /// cancellation during teardown, or an error that was already logged. + fn windowsCompleteRead( + fd: posix.fd_t, + quit: posix.fd_t, + overlapped: *windows.OVERLAPPED, + quitting: bool, + ) ?usize { + var n: windows.DWORD = 0; + if (windows.kernel32.GetOverlappedResult( + fd, + overlapped, + &n, + windows.TRUE, + ) == 0) { + const err = windows.kernel32.GetLastError(); + switch (err) { + .OPERATION_ABORTED => { + if (!quitting and !windowsQuitRequested(quit)) { + log.err("io reader completion aborted without quit signal", .{}); + } + }, + .HANDLE_EOF, .BROKEN_PIPE, .NO_DATA => {}, + else => log.err("io reader completion error err={}", .{err}), + } + return null; + } + + if (n == 0) return null; + return @intCast(n); + } }; /// Builds the argv array for the process we should exec for the From d33f4b3728e07b8fdb953505135b964ea8338f9e Mon Sep 17 00:00:00 2001 From: cmux-lawrence Date: Mon, 13 Jul 2026 12:15:51 -0700 Subject: [PATCH 09/11] fix(windows): order pseudoconsole handle teardown --- src/pty.zig | 54 ++++++++++++++++++++++++++++++++++------------------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/src/pty.zig b/src/pty.zig index 6976c474ec3..a93d45394c0 100644 --- a/src/pty.zig +++ b/src/pty.zig @@ -330,18 +330,19 @@ const WindowsPty = struct { // Process-wide counter for pipe names var pipe_name_counter = std.atomic.Value(u32).init(1); - out_pipe: windows.HANDLE, - in_pipe: windows.HANDLE, - out_pipe_pty: windows.HANDLE, - in_pipe_pty: windows.HANDLE, - pseudo_console: windows.exp.HPCON, + out_pipe: windows.HANDLE = windows.INVALID_HANDLE_VALUE, + in_pipe: windows.HANDLE = windows.INVALID_HANDLE_VALUE, + out_pipe_pty: windows.HANDLE = windows.INVALID_HANDLE_VALUE, + in_pipe_pty: windows.HANDLE = windows.INVALID_HANDLE_VALUE, + pseudo_console: ?windows.exp.HPCON = null, size: winsize, pub const OpenError = error{Unexpected}; /// Open a new PTY with the given initial size. pub fn open(size: winsize) OpenError!Pty { - var pty: Pty = undefined; + var pty: Pty = .{ .size = size }; + errdefer pty.deinit(); const pipe_id = pipe_name_counter.fetchAdd(1, .monotonic); @@ -402,7 +403,6 @@ const WindowsPty = struct { if (pty.in_pipe == windows.INVALID_HANDLE_VALUE) { return windows.unexpectedError(windows.kernel32.GetLastError()); } - errdefer _ = windows.CloseHandle(pty.in_pipe); var security_attributes_read = security_attributes; pty.in_pipe_pty = windows.kernel32.CreateFileW( @@ -417,7 +417,6 @@ const WindowsPty = struct { if (pty.in_pipe_pty == windows.INVALID_HANDLE_VALUE) { return windows.unexpectedError(windows.kernel32.GetLastError()); } - errdefer _ = windows.CloseHandle(pty.in_pipe_pty); // Both app-side handles use overlapped I/O. ConPTY requires the client // handles passed to CreatePseudoConsole to remain synchronous. @@ -436,7 +435,6 @@ const WindowsPty = struct { if (pty.out_pipe == windows.INVALID_HANDLE_VALUE) { return windows.unexpectedError(windows.kernel32.GetLastError()); } - errdefer _ = windows.CloseHandle(pty.out_pipe); var security_attributes_write = security_attributes; pty.out_pipe_pty = windows.kernel32.CreateFileW( @@ -451,33 +449,50 @@ const WindowsPty = struct { if (pty.out_pipe_pty == windows.INVALID_HANDLE_VALUE) { return windows.unexpectedError(windows.kernel32.GetLastError()); } - errdefer _ = windows.CloseHandle(pty.out_pipe_pty); try windows.SetHandleInformation(pty.in_pipe, windows.HANDLE_FLAG_INHERIT, 0); try windows.SetHandleInformation(pty.in_pipe_pty, windows.HANDLE_FLAG_INHERIT, 0); try windows.SetHandleInformation(pty.out_pipe, windows.HANDLE_FLAG_INHERIT, 0); try windows.SetHandleInformation(pty.out_pipe_pty, windows.HANDLE_FLAG_INHERIT, 0); + var pseudo_console: windows.exp.HPCON = undefined; const result = windows.exp.kernel32.CreatePseudoConsole( .{ .X = @intCast(size.ws_col), .Y = @intCast(size.ws_row) }, pty.in_pipe_pty, pty.out_pipe_pty, 0, - &pty.pseudo_console, + &pseudo_console, ); if (result != windows.S_OK) return error.Unexpected; + pty.pseudo_console = pseudo_console; - pty.size = size; return pty; } pub fn deinit(self: *Pty) void { - _ = windows.CloseHandle(self.in_pipe_pty); - _ = windows.CloseHandle(self.in_pipe); - _ = windows.CloseHandle(self.out_pipe_pty); - _ = windows.CloseHandle(self.out_pipe); - _ = windows.exp.kernel32.ClosePseudoConsole(self.pseudo_console); - self.* = undefined; + // Older Windows versions wait indefinitely in ClosePseudoConsole if + // output remains open. Closing input or the ConPTY-side output handle + // first can also make conhost enter two teardown paths concurrently. + // Keep every other endpoint valid until the app-side output is closed + // and the pseudoconsole has finished its single owner teardown. + // https://learn.microsoft.com/en-us/windows/console/closepseudoconsole + closeOwnedHandle(&self.out_pipe); + if (self.pseudo_console) |pseudo_console| { + windows.exp.kernel32.ClosePseudoConsole(pseudo_console); + self.pseudo_console = null; + } + + closeOwnedHandle(&self.out_pipe_pty); + closeOwnedHandle(&self.in_pipe_pty); + closeOwnedHandle(&self.in_pipe); + } + + /// Close a PTY endpoint at most once. This also makes cleanup safe when + /// open fails after acquiring only a prefix of the owned handles. + fn closeOwnedHandle(handle: *windows.HANDLE) void { + if (handle.* == windows.INVALID_HANDLE_VALUE) return; + _ = windows.CloseHandle(handle.*); + handle.* = windows.INVALID_HANDLE_VALUE; } pub const GetSizeError = error{}; @@ -491,8 +506,9 @@ const WindowsPty = struct { /// Set the size of the pty. pub fn setSize(self: *Pty, size: winsize) SetSizeError!void { + const pseudo_console = self.pseudo_console orelse return error.ResizeFailed; const result = windows.exp.kernel32.ResizePseudoConsole( - self.pseudo_console, + pseudo_console, .{ .X = @intCast(size.ws_col), .Y = @intCast(size.ws_row) }, ); From 0cfd305848dd10ae785ef86fec77d9a6cf3f639b Mon Sep 17 00:00:00 2001 From: cmux-lawrence Date: Mon, 13 Jul 2026 13:24:48 -0700 Subject: [PATCH 10/11] fix(windows): release ConPTY setup pipe handles --- src/pty.zig | 29 ++++++++++++++++++++++++----- src/termio/Exec.zig | 12 ++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/pty.zig b/src/pty.zig index a93d45394c0..50a3f04ee30 100644 --- a/src/pty.zig +++ b/src/pty.zig @@ -471,10 +471,11 @@ const WindowsPty = struct { pub fn deinit(self: *Pty) void { // Older Windows versions wait indefinitely in ClosePseudoConsole if - // output remains open. Closing input or the ConPTY-side output handle - // first can also make conhost enter two teardown paths concurrently. - // Keep every other endpoint valid until the app-side output is closed - // and the pseudoconsole has finished its single owner teardown. + // output remains open. The ConPTY-side setup handles are normally + // released immediately after the child starts, but error cleanup can + // reach this point before that ownership transition. Close the + // app-side output first, let the pseudoconsole finish teardown, then + // idempotently release any setup handles still owned here. // https://learn.microsoft.com/en-us/windows/console/closepseudoconsole closeOwnedHandle(&self.out_pipe); if (self.pseudo_console) |pseudo_console| { @@ -482,9 +483,18 @@ const WindowsPty = struct { self.pseudo_console = null; } + self.releasePseudoConsolePipeHandles(); + closeOwnedHandle(&self.in_pipe); + } + + /// Release the synchronous handles supplied to CreatePseudoConsole. The + /// pseudoconsole duplicates these handles, so the host must release its + /// copies after the attached child has started. Keeping them open prevents + /// broken-channel detection and makes teardown ownership ambiguous. + /// https://learn.microsoft.com/en-us/windows/console/creating-a-pseudoconsole-session#creating-the-pseudoconsole + pub fn releasePseudoConsolePipeHandles(self: *Pty) void { closeOwnedHandle(&self.out_pipe_pty); closeOwnedHandle(&self.in_pipe_pty); - closeOwnedHandle(&self.in_pipe); } /// Close a PTY endpoint at most once. This also makes cleanup safe when @@ -548,6 +558,15 @@ test { .freebsd => try testing.expect(std.mem.startsWith(u8, pty.getProcessInfo(.tty_name).?, "/dev/")), .linux => try testing.expect(std.mem.startsWith(u8, pty.getProcessInfo(.tty_name).?, "/dev/pts/")), .macos => try testing.expect(std.mem.startsWith(u8, pty.getProcessInfo(.tty_name).?, "/dev/")), + .windows => { + // The host-side copies passed to CreatePseudoConsole are released + // after child creation. Releasing them repeatedly and then running + // the deferred full teardown must remain safe. + pty.releasePseudoConsolePipeHandles(); + try testing.expectEqual(windows.INVALID_HANDLE_VALUE, pty.out_pipe_pty); + try testing.expectEqual(windows.INVALID_HANDLE_VALUE, pty.in_pipe_pty); + pty.releasePseudoConsolePipeHandles(); + }, else => try testing.expect(pty.getProcessInfo(.tty_name) == null), } } diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 5dc82b76a1d..5da3edcc175 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -1053,6 +1053,18 @@ const Subprocess = struct { else => return err, } }; + + if (comptime builtin.os.tag == .windows) { + // CreatePseudoConsole duplicates its synchronous pipe handles. Once + // CreateProcess succeeds, release our setup copies so channel + // closure is observable and only HPCON owns the ConPTY lifetime. + // `pty` and `self.pty` are value copies, so invalidate both after + // the long-lived copy performs the idempotent close. + self.pty.?.releasePseudoConsolePipeHandles(); + pty.out_pipe_pty = self.pty.?.out_pipe_pty; + pty.in_pipe_pty = self.pty.?.in_pipe_pty; + } + errdefer killCommand(&cmd) catch |err| { log.warn("error killing command during cleanup err={}", .{err}); }; From 350c96b7437b2befd558abcd3f585f7c4854ea4a Mon Sep 17 00:00:00 2001 From: cmux-lawrence Date: Mon, 13 Jul 2026 16:52:27 -0700 Subject: [PATCH 11/11] feat(embed): support terminal mirror response suppression --- include/ghostty.h | 4 +++ src/Surface.zig | 4 +++ src/apprt/embedded.zig | 12 +++++++++ src/termio/Options.zig | 4 +++ src/termio/Termio.zig | 9 ++++++- src/termio/stream_handler.zig | 49 +++++++++++++++++++++++++++++++++++ 6 files changed, 81 insertions(+), 1 deletion(-) diff --git a/include/ghostty.h b/include/ghostty.h index 3f85372f56c..fdd738709e2 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -504,6 +504,10 @@ typedef struct { ghostty_surface_io_mode_e io_mode; ghostty_io_write_cb io_write_cb; void* io_write_userdata; + // When true, terminal-protocol replies generated while parsing output are + // not forwarded to io_write_cb. Use this when another terminal core owns + // the PTY protocol and libghostty is a rendering/input mirror. + bool suppress_terminal_responses; } ghostty_surface_config_s; typedef struct { diff --git a/src/Surface.zig b/src/Surface.zig index d002bc44b9f..cb00f679faa 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -715,6 +715,10 @@ pub fn init( .full_config = config, .config = try termio.Termio.DerivedConfig.init(alloc, config), .backend = io_backend, + .suppress_terminal_responses = if (comptime @hasDecl(apprt.runtime.Surface, "suppressTerminalResponses")) + rt_surface.suppressTerminalResponses() + else + false, .mailbox = io_mailbox, .renderer_state = &self.renderer_state, .renderer_wakeup = render_thread.wakeup, diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index ca271ae1971..4171c2311b6 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -474,6 +474,7 @@ pub const Surface = struct { io_mode: IoMode = .exec, io_write_cb: ?IoWriteCallback = null, io_write_userdata: ?*anyopaque = null, + suppress_terminal_responses: bool = false, /// The current title of the surface. The embedded apprt saves this so /// that getTitle works without the implementer needing to save it. @@ -529,6 +530,11 @@ pub const Surface = struct { /// Userdata passed to io_write_cb. io_write_userdata: ?*anyopaque = null, + + /// Drop replies generated by parsing terminal output. Input encoded + /// from keyboard, text, mouse, and paste events still reaches the + /// manual IO callback. + suppress_terminal_responses: bool = false, }; pub fn init(self: *Surface, app: *App, opts: Options) !void { @@ -547,6 +553,7 @@ pub const Surface = struct { .io_mode = opts.io_mode, .io_write_cb = opts.io_write_cb, .io_write_userdata = opts.io_write_userdata, + .suppress_terminal_responses = opts.suppress_terminal_responses, }; // Add ourselves to the list of surfaces on the app. @@ -748,6 +755,10 @@ pub const Surface = struct { return self.io_write_userdata; } + pub fn suppressTerminalResponses(self: *const Surface) bool { + return self.suppress_terminal_responses; + } + pub fn getTitle(self: *Surface) ?[:0]const u8 { return self.title; } @@ -1057,6 +1068,7 @@ pub const Surface = struct { .io_mode = self.io_mode, .io_write_cb = self.io_write_cb, .io_write_userdata = self.io_write_userdata, + .suppress_terminal_responses = self.suppress_terminal_responses, }; } diff --git a/src/termio/Options.zig b/src/termio/Options.zig index a6bf8c4d447..087c7d07dd3 100644 --- a/src/termio/Options.zig +++ b/src/termio/Options.zig @@ -20,6 +20,10 @@ config: termio.Termio.DerivedConfig, /// The backend for termio that implements where reads/writes are sourced. backend: termio.Backend, +/// Drop replies generated while parsing terminal output. This is used by +/// mirror renderers when another terminal core owns the PTY protocol. +suppress_terminal_responses: bool = false, + /// The mailbox for the terminal. This is how messages are delivered. /// If you're using termio.Thread this MUST be "mailbox". mailbox: termio.Mailbox, diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 7f0856374fc..62461e17123 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -79,6 +79,9 @@ pty_tee_userdata: ?*anyopaque = null, /// from the child process and calls callbacks in the stream handler. terminal_stream: StreamHandler.Stream, +/// True when another terminal core owns protocol replies for this PTY. +suppress_terminal_responses: bool, + /// Last time the cursor was reset. This is used to prevent message /// flooding with cursor resets. last_cursor_reset: ?std.time.Instant = null, @@ -302,6 +305,7 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { .osc_color_report_format = opts.config.osc_color_report_format, .clipboard_write = opts.config.clipboard_write, .enquiry_response = opts.config.enquiry_response, + .suppress_terminal_responses = opts.suppress_terminal_responses, .default_cursor_style = opts.config.cursor_style, .default_cursor_blink = opts.config.cursor_blink, }; @@ -323,6 +327,7 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { .backend = backend, .mailbox = opts.mailbox, .terminal_stream = .initAlloc(alloc, handler), + .suppress_terminal_responses = opts.suppress_terminal_responses, .thread_enter_state = thread_enter_state, }; } @@ -634,6 +639,7 @@ pub fn sizeReport(self: *Termio, td: *ThreadData, style: termio.Message.SizeRepo } fn sizeReportLocked(self: *Termio, td: *ThreadData, style: termio.Message.SizeReport) !void { + if (self.suppress_terminal_responses) return; const grid_size = self.size.grid(); const report_size: terminalpkg.size_report.Size = .{ .rows = grid_size.rows, @@ -747,7 +753,7 @@ pub fn focusGained(self: *Termio, td: *ThreadData, focused: bool) !void { self.renderer_state.mutex.unlock(); // If we have focus events enabled, we send the focus event. - if (focus_event) { + if (focus_event and !self.suppress_terminal_responses) { var buf: [terminalpkg.focus.max_encode_size]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); terminalpkg.focus.encode(&writer, if (focused) .gained else .lost) catch |err| { @@ -841,6 +847,7 @@ pub fn colorSchemeReport(self: *Termio, td: *ThreadData, force: bool) !void { } pub fn colorSchemeReportLocked(self: *Termio, td: *ThreadData, force: bool) !void { + if (self.suppress_terminal_responses) return; if (!force and !self.renderer_state.terminal.modes.get(.report_color_scheme)) { return; } diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 993f72ef49e..0218b3779fc 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -16,6 +16,49 @@ const posix = std.posix; const log = std.log.scoped(.io_handler); const max_tmux_control_pane_output_bytes: usize = 65_536; +fn suppressTerminalResponse(enabled: bool, msg: termio.Message) bool { + if (!enabled) return false; + switch (msg) { + .write_small, + .write_stable, + .color_scheme_report, + .size_report, + .focused, + => return true, + .write_alloc => |req| { + req.alloc.free(req.data); + return true; + }, + else => return false, + } +} + +test "terminal response suppression only drops parser writes" { + const testing = std.testing; + + const small_bytes: []const u8 = "reply"; + const small = try termio.Message.writeReq(testing.allocator, small_bytes); + try testing.expect(suppressTerminalResponse(true, small)); + + const large_bytes = [_]u8{'x'} ** 80; + const large_slice: []const u8 = &large_bytes; + const large = try termio.Message.writeReq(testing.allocator, large_slice); + try testing.expect(suppressTerminalResponse(true, large)); + + try testing.expect(!suppressTerminalResponse( + false, + .{ .write_stable = "reply" }, + )); + try testing.expect(!suppressTerminalResponse( + true, + .{ .linefeed_mode = true }, + )); + try testing.expect(suppressTerminalResponse( + true, + .{ .size_report = .csi_18_t }, + )); +} + /// This is used as the handler for the terminal.Stream type. This is /// stateful and is expected to live for the entire lifetime of the terminal. /// It is NOT VALID to stop a stream handler, create a new one, and use that @@ -57,6 +100,10 @@ pub const StreamHandler = struct { /// The clipboard write access configuration. clipboard_write: configpkg.ClipboardAccess, + /// When another terminal core owns the PTY protocol, Ghostty is only a + /// render/input mirror and must not emit a second copy of protocol replies. + suppress_terminal_responses: bool = false, + //--------------------------------------------------------------- // Internal state @@ -137,6 +184,8 @@ pub const StreamHandler = struct { } inline fn messageWriter(self: *StreamHandler, msg: termio.Message) void { + if (suppressTerminalResponse(self.suppress_terminal_responses, msg)) + return; self.termio_mailbox.send(msg, self.renderer_state.mutex); self.termio_messaged = true; }