6767TESTGEN_LOG_FILE_PATH = pathlib .Path .home () / ".testgen" / "logs" / "app.log"
6868TESTGEN_CONFIG_ENV_PATH = pathlib .Path .home () / ".testgen" / "config.env"
6969TESTGEN_APP_READY_TIMEOUT = 120
70+ # Seconds TestGen is given to stop. Must exceed TG_JOB_SHUTDOWN_TIMEOUT (default 60s) plus
71+ # the time the process needs to record what stopped, or the scheduler is killed mid-wait and
72+ # a running job is cut instead of stopping at a checkpoint.
73+ TESTGEN_STOP_GRACE_PERIOD = 90
7074INSTALL_MARKER_FILE = "dk-{}-install.json"
7175INSTALL_MODE_DOCKER = "docker"
7276INSTALL_MODE_PIP = "pip"
@@ -2079,6 +2083,32 @@ def execute(self, args):
20792083 CONSOLE .msg ("Observability Heartbeat demo stopped." )
20802084
20812085
2086+ def find_in_block (contents : str , block : str , key : str ) -> typing .Optional [re .Match ]:
2087+ """Find the ``key:`` line inside the compose ``block:`` mapping, or None.
2088+
2089+ Scans by indentation rather than parsing YAML — enough for the block-style files the
2090+ installer writes, and it avoids a runtime dependency. Offsets on the returned match
2091+ are absolute, so callers can splice around it; group 1 is the key's indent.
2092+
2093+ Scoping to a block is the point: the same key can appear on several services, and
2094+ only ``engine`` runs the scheduler. Deliberately says nothing about *which* image a
2095+ service uses — ``tg install --image`` accepts any registry.
2096+ """
2097+ headers = list (re .finditer (rf"^([ \t]*){ re .escape (block )} :[ \t]*$" , contents , flags = re .M ))
2098+ if not headers :
2099+ return None
2100+ # Shallowest wins: a name like ``postgres`` is both a service and a nested key under
2101+ # another service's ``depends_on``, and it's the service the caller means.
2102+ header = min (headers , key = lambda match : len (match .group (1 )))
2103+ # The block body ends at the first line indented no deeper than the block key itself.
2104+ end = len (contents )
2105+ for line in re .finditer (r"^([ \t]*)\S.*$" , contents [header .end () :], flags = re .M ):
2106+ if len (line .group (1 )) <= len (header .group (1 )):
2107+ end = header .end () + line .start ()
2108+ break
2109+ return re .compile (rf"^([ \t]+){ re .escape (key )} :.*$" , flags = re .M ).search (contents , header .end (), end )
2110+
2111+
20822112class UpdateComposeFileStep (Step ):
20832113 label = "Updating the Docker compose file"
20842114
@@ -2088,6 +2118,7 @@ def __init__(self):
20882118 self .update_token = False
20892119 self .update_base_url = False
20902120 self .update_api_port = False
2121+ self .update_stop_grace = False
20912122 super ().__init__ ()
20922123
20932124 def pre_execute (self , action , args ):
@@ -2149,13 +2180,25 @@ def pre_execute(self, action, args):
21492180 and not re .search (rf"- \d+:{ TESTGEN_DEFAULT_API_PORT } \b" , contents )
21502181 )
21512182
2183+ # Compose files written before the grace period was added stop the engine after
2184+ # docker's 10s default, cutting a running job instead of letting it checkpoint.
2185+ # Only count it as a pending change if we can actually place it, or the step
2186+ # would report success having silently rewritten the file unchanged.
2187+ engine_image = find_in_block (contents , "engine" , "image" )
2188+ if engine_image is None :
2189+ LOG .info ("No image line in the compose 'engine' service; leaving stop_grace_period alone" )
2190+ self .update_stop_grace = (
2191+ engine_image is not None and find_in_block (contents , "engine" , "stop_grace_period" ) is None
2192+ )
2193+
21522194 if not any (
21532195 (
21542196 self .update_version ,
21552197 self .update_analytics ,
21562198 self .update_token ,
21572199 self .update_base_url ,
21582200 self .update_api_port ,
2201+ self .update_stop_grace ,
21592202 )
21602203 ):
21612204 CONSOLE .msg ("No changes will be applied." )
@@ -2169,6 +2212,7 @@ def execute(self, action, args):
21692212 self .update_token ,
21702213 self .update_base_url ,
21712214 self .update_api_port ,
2215+ self .update_stop_grace ,
21722216 )
21732217 ):
21742218 raise SkipStep
@@ -2210,6 +2254,15 @@ def execute(self, action, args):
22102254 new_mapping = f"\n { match .group (1 )} - { TESTGEN_DEFAULT_API_PORT } :{ TESTGEN_DEFAULT_API_PORT } "
22112255 contents = contents [0 : match .end ()] + new_mapping + contents [match .end () :]
22122256
2257+ if self .update_stop_grace and (image := find_in_block (contents , "engine" , "image" )):
2258+ indent = image .group (1 )
2259+ grace = (
2260+ f"\n { indent } # Must exceed TG_JOB_SHUTDOWN_TIMEOUT (default 60s), or docker kills the scheduler"
2261+ f"\n { indent } # mid-wait and running jobs are cut instead of stopping at a checkpoint."
2262+ f"\n { indent } stop_grace_period: { TESTGEN_STOP_GRACE_PERIOD } s"
2263+ )
2264+ contents = contents [: image .end ()] + grace + contents [image .end () :]
2265+
22132266 action .get_compose_file_path (args ).write_text (contents )
22142267
22152268
@@ -2309,6 +2362,9 @@ def get_compose_file_contents(self, action, args):
23092362 services:
23102363 engine:
23112364 image: { args .image }
2365+ # Must exceed TG_JOB_SHUTDOWN_TIMEOUT (default 60s), or docker kills the scheduler
2366+ # mid-wait and running jobs are cut instead of stopping at a checkpoint.
2367+ stop_grace_period: { TESTGEN_STOP_GRACE_PERIOD } s
23122368 container_name: testgen
23132369 environment: *common-variables
23142370 volumes:
@@ -2524,17 +2580,25 @@ def read_testgen_config_env() -> dict[str, str]:
25242580 return config
25252581
25262582
2527- def stop_app_tree ( proc : subprocess . Popen , timeout : int = 10 ) -> None :
2528- """Terminate ``proc`` and all of its descendants .
2583+ def supports_graceful_stop ( ) -> bool :
2584+ """Whether we can ask the app to stop rather than kill it outright .
25292585
2530- Plain ``proc.terminate()`` only kills the parent — pixeltable-pgserver
2531- spawns ``postgres`` children that get orphaned otherwise. Cross-platform:
2532- on Windows we shell out to ``taskkill /F /T``; on POSIX we send SIGTERM
2533- to the whole process group (the parent was started with
2534- ``start_new_session=True``).
2586+ POSIX only. On Windows the app is started without ``CREATE_NEW_PROCESS_GROUP``,
2587+ so there is no catchable signal we can deliver to it and ``taskkill /F`` is the
2588+ only reliable stop — a running job is cut there whatever grace period we would
2589+ nominally allow. Callers use this to avoid promising a wait that can't happen.
2590+ """
2591+ return platform .system () != "Windows"
2592+
2593+
2594+ def force_kill_app_tree (proc : subprocess .Popen , timeout : int = 5 ) -> None :
2595+ """Kill ``proc`` and every descendant, including those outside its process group.
2596+
2597+ ``testgen run-app all`` starts its ui/scheduler children in their own sessions, so
2598+ they sit outside ``proc``'s process group and outlive a ``killpg`` — killing only
2599+ the parent leaves the UI holding its port and postgres holding the data directory,
2600+ which then breaks the next ``tg start``. Hence the orphan sweep to finish the job.
25352601 """
2536- if proc .poll () is not None :
2537- return
25382602 if platform .system () == "Windows" :
25392603 with contextlib .suppress (Exception ):
25402604 subprocess .run (
@@ -2546,16 +2610,51 @@ def stop_app_tree(proc: subprocess.Popen, timeout: int = 10) -> None:
25462610 )
25472611 else :
25482612 with contextlib .suppress (Exception ):
2549- os .killpg (os .getpgid (proc .pid ), signal .SIGTERM )
2613+ os .killpg (os .getpgid (proc .pid ), signal .SIGKILL )
2614+ # Backstop in case the platform kill above didn't land (e.g. taskkill denied).
2615+ proc .kill ()
2616+ with contextlib .suppress (subprocess .TimeoutExpired ):
2617+ proc .wait (timeout = timeout )
2618+ stop_standalone_orphans ()
2619+
2620+
2621+ def stop_app_tree (proc : subprocess .Popen , timeout : int = 10 ) -> bool :
2622+ """Terminate ``proc`` and all of its descendants. Returns whether it stopped on its own.
2623+
2624+ Plain ``proc.terminate()`` only kills the parent — pixeltable-pgserver
2625+ spawns ``postgres`` children that get orphaned otherwise. Cross-platform:
2626+ on Windows we shell out to ``taskkill /F /T``; on POSIX we send SIGTERM
2627+ to the whole process group (the parent was started with
2628+ ``start_new_session=True``) and let it forward the signal to its children.
2629+
2630+ ``timeout`` is how long the tree is given to shut down cooperatively before
2631+ it is force-killed — the caller passes ``TESTGEN_STOP_GRACE_PERIOD`` when a
2632+ running job may need to reach a checkpoint first. A second Ctrl+C during
2633+ that wait is taken as "stop now" and skips straight to the force-kill.
2634+
2635+ Returns ``True`` when the tree exited within ``timeout`` (or was already
2636+ gone), ``False`` when it had to be force-killed. See ``supports_graceful_stop``
2637+ for why Windows always reports ``False`` when there was a live process.
2638+ """
2639+ if proc .poll () is not None :
2640+ return True
2641+
2642+ if not supports_graceful_stop ():
2643+ force_kill_app_tree (proc , timeout = timeout )
2644+ return False
2645+
2646+ with contextlib .suppress (Exception ):
2647+ os .killpg (os .getpgid (proc .pid ), signal .SIGTERM )
25502648 try :
25512649 proc .wait (timeout = timeout )
2552- except subprocess .TimeoutExpired :
2553- if platform .system () != "Windows" :
2554- with contextlib .suppress (Exception ):
2555- os .killpg (os .getpgid (proc .pid ), signal .SIGKILL )
2556- proc .kill ()
2557- with contextlib .suppress (subprocess .TimeoutExpired ):
2558- proc .wait (timeout = 5 )
2650+ except (subprocess .TimeoutExpired , KeyboardInterrupt ):
2651+ # KeyboardInterrupt here is a second Ctrl+C while we were waiting. Swallow it
2652+ # rather than letting it unwind into the caller's ``finally``, which would send
2653+ # the tree another SIGTERM — TestGen reads a second signal as "hurry up" and
2654+ # force-kills the job mid-pause, losing exactly the checkpoint we were waiting for.
2655+ force_kill_app_tree (proc )
2656+ return False
2657+ return True
25592658
25602659
25612660def stop_standalone_orphans () -> None :
@@ -2687,8 +2786,21 @@ def start_testgen_app(action, args) -> None:
26872786 # Reset the cursor to column 0 — the terminal echoed `^C` mid-line.
26882787 print ("" )
26892788 CONSOLE .msg ("Stopping TestGen..." )
2690- stop_app_tree (proc , timeout = 10 )
2691- CONSOLE .msg ("TestGen stopped." )
2789+ graceful = supports_graceful_stop ()
2790+ if graceful :
2791+ # A running profiling job stops at its next checkpoint rather than being cut,
2792+ # but that can take up to TG_JOB_SHUTDOWN_TIMEOUT — say so, or the wait reads
2793+ # as a hang and the user reaches for a second Ctrl+C.
2794+ CONSOLE .msg (
2795+ f"Waiting up to { TESTGEN_STOP_GRACE_PERIOD } seconds for running jobs to reach a checkpoint..."
2796+ )
2797+ stopped_cleanly = stop_app_tree (proc , timeout = TESTGEN_STOP_GRACE_PERIOD )
2798+ # Only worth flagging where we actually offered a grace period. Windows always
2799+ # force-kills, so the warning would fire on every stop and mean nothing.
2800+ if graceful and not stopped_cleanly :
2801+ CONSOLE .msg ("TestGen stopped. A job that was still running will restart from the beginning." )
2802+ else :
2803+ CONSOLE .msg ("TestGen stopped." )
26922804 CONSOLE .msg (f"To start it again, { command_hint (args .prod , 'start' , 'Start TestGen' )} ." )
26932805 finally :
26942806 stop_app_tree (proc , timeout = 5 )
0 commit comments