From 36b0ef9805723ca509d6a36fe8ff4b0964c1f4e0 Mon Sep 17 00:00:00 2001 From: Sergey Fedorov Date: Tue, 21 Jul 2026 13:06:38 +0000 Subject: [PATCH 64/71] feat(macos): libdispatch codepath for main-thread marshaling (opt-in) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a compile-time alternative to performSelectorOnMainThread: for the parser/SSH-thread -> main-thread callback hops in TerminalView.mm (redraw, title, bell, clipboard, close, cwd-change, SSH hostkey prompt), gated by a new CMake option CONTOUR_USE_LIBDISPATCH (default OFF — performSelectorOnMainThread: stays the default, longer-proven path). - callOnMainThread / callOnMainThreadSync wrap dispatch_async_f / dispatch_sync_f (libdispatch's function-pointer form, available on 10.6+) when the flag is on, or performSelectorOnMainThread:waitUntilDone: (NO/YES) otherwise. No Blocks syntax anywhere — Apple's gcc-4.2 can compile blocks, but the modern-gcc OBJCXX path (the other frontend compiler this port supports) may lack a Blocks runtime, so this avoids depending on blocks at all. - dispatch_async_f does not retain its context, unlike performSelectorOnMainThread:, so the async path's context struct explicitly retains target+argument and releases them after the call runs. - CONTOUR_USE_LIBDISPATCH is threaded into both frontend compile paths (the gcc-4.2 custom-compile command and the modern-OBJCXX default) as a -D define; no new link dependency (libdispatch ships in libSystem on 10.6+). Confined entirely to src/contour_macos/ (our GUI frontend); no engine changes. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LzTmVgP2ruMz987VJ78k77 --- docs/macos-port.md | 20 ++++-- src/contour_macos/CMakeLists.txt | 23 ++++++ src/contour_macos/TerminalView.mm | 112 ++++++++++++++++++++++++++---- 3 files changed, 138 insertions(+), 17 deletions(-) diff --git a/docs/macos-port.md b/docs/macos-port.md index dd44e7e7..bae1880e 100644 --- a/docs/macos-port.md +++ b/docs/macos-port.md @@ -153,11 +153,21 @@ Three logical roles, collapsed from Qt's four to three here: The one cross-thread seam to replace is upstream's `postToObject` (`QMetaObject::invokeMethod(..., Qt::QueuedConnection)`): every backend→GUI hop -(schedule-redraw, title change, resize request, …) goes through it. It is replaced by -a single function that enqueues onto the main run loop — -`dispatch_async(dispatch_get_main_queue(), ^{ … })` (libdispatch, available on 10.6+), -or `CFRunLoopPerformBlock` + `CFRunLoopWakeUp`. Dispatched blocks must re-check object -liveness (window/session still alive) before touching them, as the Qt lambdas do. +(schedule-redraw, title change, resize request, bell, clipboard, close, SSH hostkey +prompt, …) goes through it. `TerminalView.mm` implements two interchangeable +marshaling paths, selected at compile time by `CONTOUR_USE_LIBDISPATCH` (CMake option, +default OFF): +- **Default**: `performSelectorOnMainThread:withObject:waitUntilDone:` (NO for + fire-and-forget calls, YES for the synchronous SSH hostkey prompt). The longer-proven + path on this port. +- **`CONTOUR_USE_LIBDISPATCH=ON`**: `dispatch_async_f`/`dispatch_sync_f` (libdispatch, + available on 10.6+) via `callOnMainThread`/`callOnMainThreadSync`. Deliberately the + function-pointer form, not `dispatch_async`/blocks — Apple's gcc-4.2 (the frontend's + known-good ObjC++ compiler, see the CMakeLists.txt note by the same name) can compile + blocks, but this port avoids them everywhere to not depend on that; the modern-gcc + OBJCXX path may not have a Blocks runtime available at all. The dispatched context + explicitly retains/releases its target+argument (dispatch_async_f does not retain + anything itself, unlike performSelectorOnMainThread:). Cross-thread render safety is preserved by continuing to use `refreshRenderBuffer()` + `renderBuffer()` (double buffer + RAII front-buffer lock) diff --git a/src/contour_macos/CMakeLists.txt b/src/contour_macos/CMakeLists.txt index 6b74c09c..e2a62704 100644 --- a/src/contour_macos/CMakeLists.txt +++ b/src/contour_macos/CMakeLists.txt @@ -94,6 +94,17 @@ Empty = use the modern default OBJCXX compiler.") set(CONTOUR_MACOS_OBJCXX_STD "gnu++98" CACHE STRING "C++ standard for the frontend ObjC++ .mm files.") + # Marshals parser/SSH-thread -> main-thread callbacks (redraw, title, bell, clipboard, + # close, hostkey prompt) in TerminalView.mm via libdispatch (dispatch_async_f/ + # dispatch_sync_f — the function-pointer form, no Blocks runtime required) instead of + # performSelectorOnMainThread:. libdispatch ships in libSystem on 10.6+, so this needs no + # extra link flags, only the define below (read by TerminalView.mm's #if + # CONTOUR_USE_LIBDISPATCH). Off by default: performSelectorOnMainThread: is the + # longer-proven path on this port; opt in once the libdispatch path has been exercised. + option(CONTOUR_USE_LIBDISPATCH + "Use libdispatch (dispatch_async_f/dispatch_sync_f) instead of performSelectorOnMainThread: \ +for the frontend's parser/SSH-thread -> main-thread callback marshaling." OFF) + set(_app_headers TerminalView.h SessionBridge.h) set(_app_mm "${CMAKE_CURRENT_SOURCE_DIR}/TerminalView.mm" @@ -123,6 +134,12 @@ Empty = use the modern default OBJCXX compiler.") # quotes, which the compiler then treats as part of the directory name. set(_fe_includes -I "${PROJECT_SOURCE_DIR}/src") + if(CONTOUR_USE_LIBDISPATCH) + set(_fe_defines -D CONTOUR_USE_LIBDISPATCH=1) + else() + set(_fe_defines -D CONTOUR_USE_LIBDISPATCH=0) + endif() + set(_app_objs "") foreach(_mm IN LISTS _app_mm) get_filename_component(_name "${_mm}" NAME_WE) @@ -134,6 +151,7 @@ Empty = use the modern default OBJCXX compiler.") -std=${CONTOUR_MACOS_OBJCXX_STD} ${_fe_flags} -O2 -DNDEBUG + ${_fe_defines} ${_fe_includes} -c "${_mm}" -o "${_obj}" @@ -154,6 +172,11 @@ Empty = use the modern default OBJCXX compiler.") set_target_properties(contour PROPERTIES OBJCXX_STANDARD_REQUIRED OFF) target_compile_options(contour PRIVATE $<$:-std=${CONTOUR_MACOS_OBJCXX_STD}>) + if(CONTOUR_USE_LIBDISPATCH) + target_compile_definitions(contour PRIVATE CONTOUR_USE_LIBDISPATCH=1) + else() + target_compile_definitions(contour PRIVATE CONTOUR_USE_LIBDISPATCH=0) + endif() endif() target_link_libraries(contour PRIVATE contour_macos) diff --git a/src/contour_macos/TerminalView.mm b/src/contour_macos/TerminalView.mm index c82a1acd..e9e8a065 100644 --- a/src/contour_macos/TerminalView.mm +++ b/src/contour_macos/TerminalView.mm @@ -13,11 +13,103 @@ #include #include +#if CONTOUR_USE_LIBDISPATCH +// libdispatch is present on 10.6+, but the *block* syntax it's normally used with needs a +// Blocks runtime this frontend cannot assume (gcc-4.2 builds it under gnu++98). Use the +// function-pointer form (dispatch_async_f/dispatch_sync_f) instead: no blocks anywhere. +#include +#endif + namespace { using contour_macos::TerminalSession; +#if CONTOUR_USE_LIBDISPATCH + +// Context for a fire-and-forget main-thread hop: retains `target` for the dispatched call's +// lifetime (dispatch_async_f does not retain anything itself, unlike performSelectorOnMainThread:, +// which implicitly retains its target/argument until the call runs) and carries one optional +// argument, released after the call. +struct MainThreadCallContext +{ + id target; + SEL selector; + id argument; +}; + +void runMainThreadCall(void* rawContext) +{ + MainThreadCallContext* context = static_cast(rawContext); + [context->target performSelector:context->selector withObject:context->argument]; + [context->argument release]; + [context->target release]; + delete context; +} + +// Async equivalent of [target performSelectorOnMainThread:selector withObject:argument +// waitUntilDone:NO], via dispatch_async_f (no Blocks runtime required). `argument` may be nil. +void dispatchToMainThread(id target, SEL selector, id argument) +{ + MainThreadCallContext* context = new MainThreadCallContext; + context->target = [target retain]; + context->selector = selector; + context->argument = [argument retain]; + dispatch_async_f(dispatch_get_main_queue(), context, runMainThreadCall); +} + +// Context for a blocking main-thread hop: no retain needed (the calling thread blocks until +// the call returns, so `target`/`argument` are kept alive by the caller's own stack frame). +struct MainThreadSyncCallContext +{ + id target; + SEL selector; + id argument; +}; + +void runMainThreadSyncCall(void* rawContext) +{ + MainThreadSyncCallContext* context = static_cast(rawContext); + [context->target performSelector:context->selector withObject:context->argument]; +} + +// Blocking equivalent of [target performSelectorOnMainThread:selector withObject:argument +// waitUntilDone:YES], via dispatch_sync_f. +void dispatchToMainThreadSync(id target, SEL selector, id argument) +{ + MainThreadSyncCallContext context; + context.target = target; + context.selector = selector; + context.argument = argument; + dispatch_sync_f(dispatch_get_main_queue(), &context, runMainThreadSyncCall); +} + +#endif // CONTOUR_USE_LIBDISPATCH + +// Fire-and-forget hop from the parser/SSH thread to the main thread. Two implementations +// selected at compile time by CONTOUR_USE_LIBDISPATCH: libdispatch's dispatch_async_f, or the +// always-available performSelectorOnMainThread: fallback (identical observable behavior — +// asynchronous, retains target+argument for the call's duration). +void callOnMainThread(id target, SEL selector, id argument) +{ +#if CONTOUR_USE_LIBDISPATCH + dispatchToMainThread(target, selector, argument); +#else + [target performSelectorOnMainThread:selector withObject:argument waitUntilDone:NO]; +#endif +} + +// Blocking hop from the parser/SSH thread to the main thread; returns once the call has run. +// Used only for the SSH host-key prompt, which must complete before the caller can proceed. +void callOnMainThreadSync(id target, SEL selector, id argument) +{ +#if CONTOUR_USE_LIBDISPATCH + dispatchToMainThreadSync(target, selector, argument); +#else + [target performSelectorOnMainThread:selector withObject:argument waitUntilDone:YES]; +#endif +} + uint32_t bridgeModifiers(NSUInteger flags) { uint32_t mods = 0; @@ -67,28 +159,28 @@ int bridgeSpecialKey(unichar ch) void cbRequestRedraw(void* userData) { TerminalView* view = (TerminalView*) userData; - [view performSelectorOnMainThread:@selector(mainThreadRedraw) withObject:nil waitUntilDone:NO]; + callOnMainThread(view, @selector(mainThreadRedraw), nil); } void cbSetTitle(void* userData, char const* utf8) { TerminalView* view = (TerminalView*) userData; NSString* t = [[NSString alloc] initWithUTF8String:utf8]; - [view performSelectorOnMainThread:@selector(mainThreadSetTitle:) withObject:t waitUntilDone:NO]; + callOnMainThread(view, @selector(mainThreadSetTitle:), t); [t release]; } void cbBell(void* userData) { TerminalView* view = (TerminalView*) userData; - [view performSelectorOnMainThread:@selector(mainThreadBell) withObject:nil waitUntilDone:NO]; + callOnMainThread(view, @selector(mainThreadBell), nil); } void cbCopyToClipboard(void* userData, char const* utf8) { TerminalView* view = (TerminalView*) userData; NSString* s = [[NSString alloc] initWithUTF8String:utf8]; - [view performSelectorOnMainThread:@selector(mainThreadCopyToClipboard:) withObject:s waitUntilDone:NO]; + callOnMainThread(view, @selector(mainThreadCopyToClipboard:), s); [s release]; } @@ -106,16 +198,14 @@ char* cbReadClipboard(void* userData) void cbOnClosed(void* userData) { TerminalView* view = (TerminalView*) userData; - [view performSelectorOnMainThread:@selector(mainThreadClose) withObject:nil waitUntilDone:NO]; + callOnMainThread(view, @selector(mainThreadClose), nil); } void cbWorkingDirectoryChanged(void* userData, char const* utf8Cwd) { (void) utf8Cwd; // the view re-reads the cwd from the session on the main thread TerminalView* view = (TerminalView*) userData; - [view performSelectorOnMainThread:@selector(updateTitleFromWorkingDirectory) - withObject:nil - waitUntilDone:NO]; + callOnMainThread(view, @selector(updateTitleFromWorkingDirectory), nil); } } // namespace @@ -148,9 +238,7 @@ int cbVerifySshHostkey(void* userData, char const* host, int port, char const* f prompt->fingerprint = fingerprint ? [NSString stringWithUTF8String:fingerprint] : @""; prompt->accepted = NO; // Run the modal alert on the main thread and block this (SSH) thread until the user answers. - [(TerminalView*) userData performSelectorOnMainThread:@selector(mainThreadVerifyHostkey:) - withObject:prompt - waitUntilDone:YES]; + callOnMainThreadSync((TerminalView*) userData, @selector(mainThreadVerifyHostkey:), prompt); BOOL const accepted = prompt->accepted; [prompt release]; return accepted ? 1 : 0; @@ -364,7 +452,7 @@ int cbVerifySshHostkey(void* userData, char const* host, int port, char const* f - (void)requestRedraw { - [self performSelectorOnMainThread:@selector(mainThreadRedraw) withObject:nil waitUntilDone:NO]; + callOnMainThread(self, @selector(mainThreadRedraw), nil); } - (void)mainThreadRedraw