1+ """GUI entry point for JABS.
2+
3+ All non-stdlib imports are deferred into :func:`main` rather than performed at
4+ module scope. On macOS the process pool uses the ``forkserver`` start method (see
5+ :func:`_select_start_method`): its server process imports this module to locate
6+ worker functions but never runs :func:`main`, so keeping the imports out of module
7+ scope keeps that server free of Qt/Foundation. Otherwise the workers it forks
8+ would abort via the Objective-C fork-safety guard (surfacing as
9+ ``BrokenProcessPool``) on their first Accelerate or Qt call.
10+ """
11+
112import argparse
213import contextlib
314import logging
415import multiprocessing
516import os
617import sys
718
8- # PERFORMANCE FIX: Use fork instead of spawn for faster process creation on macOS
9- #
10- # Background: Initializing JABS-AppProcessPool on macOS was taking significant time, which
11- # got significantly worse (25s) with macOS Tahoe (maybe Sequoia+ ?)
12- # Potential cause: macOS Sequoia+ scans adhoc-signed executables on every spawn,
13- # causing significant overhead per worker process. Using fork() avoids this entirely.
14- #
15- # Why it's faster:
16- # - Workers inherit parent's memory (no re-importing modules)
17- # - No new executable spawned (no macOS security scans)
18- #
19- # Safety considerations:
20- # - fork() is generally unsafe with multi-threaded programs
21- # - Qt uses threads internally, so there's some risk
22- # - We mitigate this by:
23- # 1. Initialize pool BEFORE Qt initializes (forked from single-threaded state)
24- # 2. Workers only read files and do data processing (no Qt usage)
25- # 3. Extensive testing shows stability in practice
26- #
27- # TODO: Test Windows to see if there is benefit to using "fork" there as well.
28- if sys .platform == "darwin" :
29- # try to use 'fork' start method on macOS, suppress RuntimeError if it fails -- we'll fall back to default
30- with contextlib .suppress (RuntimeError ):
31- multiprocessing .set_start_method ("fork" , force = True )
32-
33- # suppress some potential harmless warnings from Chromium when user opens UserGuideDialog on some platforms
34- # we need to set these before importing PySide6.QtWebEngine, so we do it before all PySide6 and JABS imports
35- os .environ ["QTWEBENGINE_CHROMIUM_FLAGS" ] = (
36- "--disable-skia-graphite --disable-logging --log-level=3"
37- )
38- os .environ ["QT_LOGGING_RULES" ] = "qt.webenginecontext=false"
39- from PySide6 import QtWidgets
40- from PySide6 .QtGui import QIcon
41-
42- from jabs .core .constants import APP_NAME , APP_NAME_LONG , ORG_NAME
43- from jabs .core .utils .process_pool_manager import ProcessPoolManager
44- from jabs .resources import ICON_PATH
45- from jabs .ui import MainWindow
46- from jabs .version import version_str
47-
48- # Set log level from environment variable if present
49- log_level_str = os .environ .get ("JABS_LOG_LEVEL" , "WARNING" ).upper ()
50- try :
51- log_level = getattr (logging , log_level_str )
52- except AttributeError :
53- log_level = logging .WARNING
54- logger = logging .getLogger ("jabs.gui_entrypoint" )
55- logger .warning (f"Invalid JABS_LOG_LEVEL '{ log_level_str } ', defaulting to WARNING." )
56- logging .basicConfig (level = log_level )
5719logger = logging .getLogger ("jabs.gui_entrypoint" )
5820
5921
60- # logger wasn't setup when we set the multiprocessing start method
61- # if we need to log anything related to that, do it here
62- if sys .platform == "darwin" and multiprocessing .get_start_method () != "fork" :
63- logger .warning (
64- "Failed to set multiprocessing start method to 'fork' on macOS, "
65- "this may lead to slower process pool initialization."
66- )
22+ def _configure_logging () -> None :
23+ """Configure root logging from the ``JABS_LOG_LEVEL`` env var (default WARNING)."""
24+ log_level_str = os .environ .get ("JABS_LOG_LEVEL" , "WARNING" ).upper ()
25+ log_level = getattr (logging , log_level_str , None )
26+ if not isinstance (log_level , int ):
27+ logging .basicConfig (level = logging .WARNING )
28+ logger .warning ("Invalid JABS_LOG_LEVEL '%s', defaulting to WARNING." , log_level_str )
29+ return
30+ logging .basicConfig (level = log_level )
6731
6832
69- def main () :
70- """main entrypoint for JABS video labeling and classifier GUI
33+ def _select_start_method () -> None :
34+ """Select the multiprocessing start method (macOS only).
7135
72- takes one optional positional argument: path to project directory
36+ macOS: use ``forkserver``. ``fork`` is unsafe here -- forked workers abort
37+ via the Objective-C fork-safety guard when they call into Apple Accelerate
38+ (numpy/scipy) or Qt/Foundation, surfacing as ``BrokenProcessPool`` during
39+ feature generation. ``spawn`` is safe but cold-starts a fresh interpreter
40+ per worker (~15-20s on first project load). ``forkserver`` forks workers
41+ from a single pre-warmed, Qt/Accelerate-free server: fast like ``fork``
42+ and safe like ``spawn``. See KLAUS-525.
43+
44+ Other platforms keep their default (Linux fork/forkserver, Windows spawn).
45+
46+ Configure logging before calling this: it warns (rather than failing) if the
47+ method could not be set, so the degraded state is at least visible.
7348 """
49+ if sys .platform != "darwin" :
50+ return
51+ with contextlib .suppress (RuntimeError ):
52+ multiprocessing .set_start_method ("forkserver" , force = True )
53+ method = multiprocessing .get_start_method ()
54+ if method != "forkserver" :
55+ # The macOS default is 'spawn' (safe but slow); 'fork' would be unstable.
56+ # Warn rather than abort -- spawn still works, just with a slow first load.
57+ logger .warning (
58+ "Could not set multiprocessing start method to 'forkserver' (got '%s'); "
59+ "worker startup will be slower, and 'fork' would risk the worker-pool crash." ,
60+ method ,
61+ )
62+
63+
64+ def main () -> None :
65+ """Main entry point for the JABS video labeling and classifier GUI.
66+
67+ Takes one optional positional argument: path to a project directory to open.
68+ """
69+ _configure_logging ()
70+ _select_start_method ()
71+
72+ # Deferred imports (see module docstring). The QtWebEngine flags must be set
73+ # before importing anything that pulls in QtWebEngine (jabs.ui), so these
74+ # os.environ lines stay above the imports below.
75+ os .environ ["QTWEBENGINE_CHROMIUM_FLAGS" ] = (
76+ "--disable-skia-graphite --disable-logging --log-level=3"
77+ )
78+ os .environ ["QT_LOGGING_RULES" ] = "qt.webenginecontext=false"
79+ from PySide6 import QtWidgets
80+ from PySide6 .QtGui import QIcon
81+
82+ from jabs .core .constants import APP_NAME , APP_NAME_LONG , ORG_NAME
83+ from jabs .core .utils .process_pool_manager import ProcessPoolManager
84+ from jabs .project .parallel_workers import preload_worker_modules
85+ from jabs .resources import ICON_PATH
86+ from jabs .ui import MainWindow
87+ from jabs .version import version_str
88+
7489 parser = argparse .ArgumentParser ()
7590 parser .add_argument (
7691 "project_dir" , nargs = "?" , help = "Path to JABS project directory to open on startup"
7792 )
7893 parser .add_argument ("--version" , action = "version" , version = f"JABS { version_str ()} " )
7994 args = parser .parse_args ()
8095
81- # CRITICAL: Create and warm the process pool BEFORE QApplication
82- # QApplication creates threads; forking after that can be unsafe
83- logger .info ("Initializing process pool (before Qt)..." )
84- logger .debug (f"multiprocessing start method: '{ multiprocessing .get_start_method ()} '" )
85- process_pool = ProcessPoolManager (name = "JABS-AppProcessPool" )
86- if multiprocessing .get_start_method () == "fork" :
87- # on fork platforms, start the pool and wait for workers to be ready
88- process_pool .warm_up (wait = True )
89- else :
90- # on non-fork platforms, start the pool without waiting, workers will be spawned on-demand
91- process_pool .warm_up (wait = False )
92- logger .info (f"Process pool ready ({ process_pool .max_workers } workers)" )
93-
94- # Now safe to create QApplication (fork already happened)
96+ # Warm the pool up front on fork/forkserver so worker start-up (and the
97+ # initializer's module preloading) is paid once at launch, making the first
98+ # project load / training run instant. Leave spawn (e.g. Windows) lazy so it
99+ # doesn't slow GUI launch -- those workers start on first use instead.
100+ start_method = multiprocessing .get_start_method ()
101+ logger .info ("Initializing process pool (start method: '%s')..." , start_method )
102+ process_pool = ProcessPoolManager (
103+ name = "JABS-AppProcessPool" , initializer = preload_worker_modules
104+ )
105+ process_pool .warm_up (wait = start_method in ("fork" , "forkserver" ))
106+ logger .info ("Process pool ready (%d workers)" , process_pool .max_workers )
107+
95108 app = QtWidgets .QApplication (sys .argv )
96109 app .setApplicationName (APP_NAME )
97110 app .setOrganizationName (ORG_NAME )
@@ -103,16 +116,14 @@ def main():
103116 main_window .show ()
104117
105118 if args .project_dir is not None :
106- # this forces the GUI to process events before opening the project
107- # this is necessary to avoid a race condition where the main window
108- # is not fully initialized before trying to open the project
119+ # force the GUI to process events before opening the project to avoid a
120+ # race where the main window is not fully initialized before opening
109121 QtWidgets .QApplication .processEvents ()
110122 try :
111123 main_window .open_project (args .project_dir )
112124 except Exception as e :
113125 sys .exit (f"Error opening project: { e } " )
114126
115- # user accepted license terms, run the main application loop
116127 sys .exit (app .exec ())
117128
118129
0 commit comments