@@ -684,6 +684,10 @@ def configure_logging(self, debug=False):
684684 "class" : "logging.FileHandler" ,
685685 "filename" : str (file_path ),
686686 "formatter" : "file" ,
687+ # Default is locale.getpreferredencoding(), which is
688+ # cp1252 on US Windows — chokes on non-ASCII chars like
689+ # ✓ that the installer prints in prereq status lines.
690+ "encoding" : "utf-8" ,
687691 },
688692 "console" : {
689693 "level" : "DEBUG" ,
@@ -2508,6 +2512,71 @@ def stop_app_tree(proc: subprocess.Popen, timeout: int = 10) -> None:
25082512 proc .wait (timeout = 5 )
25092513
25102514
2515+ def stop_standalone_orphans () -> None :
2516+ """Best-effort kill of orphan ``testgen`` + embedded ``postgres`` processes
2517+ left over from a previous dirty exit.
2518+
2519+ Called before steps that need a clean slate (``tg delete`` and the
2520+ standalone-setup step of ``tg install``). Silent on the happy path —
2521+ only logs when something is actually killed.
2522+
2523+ Postgres is targeted by PID via ``<pgdata>/postmaster.pid`` so a user's
2524+ other Postgres installs aren't touched. ``testgen.exe`` is targeted by
2525+ image name on Windows — the installer itself is ``dk-installer.exe``,
2526+ so there's no risk of self-kill. Killing ``testgen.exe`` before
2527+ ``uv tool uninstall`` also matters on Windows: a running .exe holds an
2528+ exclusive file lock, so ``uv`` would otherwise fail to delete the binary.
2529+ """
2530+ # Outer guard so a transient filesystem/permission glitch in this best-effort
2531+ # cleanup can never crash the install or delete flow.
2532+ try :
2533+ tg_home_env = os .environ .get ("TG_TESTGEN_HOME" )
2534+ tg_home = pathlib .Path (tg_home_env ) if tg_home_env else pathlib .Path .home () / ".testgen"
2535+ pid_file = tg_home / "pgdata" / "postmaster.pid"
2536+ is_windows = platform .system () == "Windows"
2537+
2538+ if pid_file .exists ():
2539+ with contextlib .suppress (Exception ):
2540+ postgres_pid = int (pid_file .read_text ().splitlines ()[0 ].strip ())
2541+ LOG .info ("Stopping orphan postgres (PID %d) from previous session" , postgres_pid )
2542+ if is_windows :
2543+ subprocess .run (
2544+ ["taskkill" , "/F" , "/T" , "/PID" , str (postgres_pid )],
2545+ stdout = subprocess .DEVNULL ,
2546+ stderr = subprocess .DEVNULL ,
2547+ creationflags = getattr (subprocess , "CREATE_NO_WINDOW" , 0 ),
2548+ check = False ,
2549+ )
2550+ else :
2551+ with contextlib .suppress (ProcessLookupError ):
2552+ os .kill (postgres_pid , signal .SIGKILL )
2553+
2554+ if is_windows :
2555+ # Image-name match — covers any leftover `testgen run-app` parents.
2556+ # `/T` propagates to their children (UI/scheduler/server subprocesses).
2557+ with contextlib .suppress (Exception ):
2558+ subprocess .run (
2559+ ["taskkill" , "/F" , "/T" , "/IM" , "testgen.exe" ],
2560+ stdout = subprocess .DEVNULL ,
2561+ stderr = subprocess .DEVNULL ,
2562+ creationflags = getattr (subprocess , "CREATE_NO_WINDOW" , 0 ),
2563+ check = False ,
2564+ )
2565+ else :
2566+ # `pkill -f` matches against the full command line. The installer's own
2567+ # argv is `python dk-installer.py …` — doesn't contain `run-app`, so
2568+ # no self-kill risk.
2569+ with contextlib .suppress (Exception ):
2570+ subprocess .run (
2571+ ["pkill" , "-9" , "-f" , r"testgen.*run-app" ],
2572+ stdout = subprocess .DEVNULL ,
2573+ stderr = subprocess .DEVNULL ,
2574+ check = False ,
2575+ )
2576+ except Exception :
2577+ LOG .exception ("Unexpected error during orphan cleanup; continuing" )
2578+
2579+
25112580def start_testgen_app (action , args ) -> None :
25122581 """Start ``testgen run-app`` and block until the user interrupts.
25132582
@@ -2624,6 +2693,10 @@ def __init__(self):
26242693 def pre_execute (self , action , args ):
26252694 self .username = DEFAULT_USER_DATA ["username" ]
26262695 self .password = generate_password ()
2696+ # Reach here only after `_resolve_install_mode` confirmed no existing
2697+ # install marker — so any running testgen/postgres processes are
2698+ # orphans from a previous dirty exit, safe to force-kill.
2699+ stop_standalone_orphans ()
26272700
26282701 def execute (self , action , args ):
26292702 # standalone-setup persists these env vars to ~/.testgen/config.env so
@@ -3102,6 +3175,13 @@ def _delete_docker(self, args):
31023175 def _delete_pip (self , args ):
31033176 CONSOLE .title ("Delete TestGen instance" )
31043177
3178+ # Stop any running testgen + embedded postgres before touching the
3179+ # installation. On Windows, a live testgen.exe locks its own binary
3180+ # so `uv tool uninstall` would fail to remove it; on either platform,
3181+ # a live postgres holds file handles into ~/.testgen that block
3182+ # `shutil.rmtree` from completing cleanly.
3183+ stop_standalone_orphans ()
3184+
31053185 uv_path = resolve_uv_path (self .data_folder )
31063186 if uv_path :
31073187 try :
0 commit comments