Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions lib/python/base_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,11 +327,12 @@ def _capture_effective_output_options(
state.json_output = json_output


def _record_unexpected_traceback(context: Context[Any, Any, Any], outcome: InvocationOutcome) -> None:
if outcome.kind != "unexpected_error":
return
def _record_lifecycle_diagnostic(context: Context[Any, Any, Any], outcome: InvocationOutcome) -> None:
try:
context.log.debug("Unexpected command exception", exc_info=True)
if outcome.kind == "interrupted":

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this work for other types of signals like SIGTERM or just for SIGINT?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. This change is specifically for Ctrl-C/SIGINT: base-cli normalizes KeyboardInterrupt to the interrupted outcome and logs it before lifecycle teardown. SIGTERM does not raise KeyboardInterrupt; by default it terminates the process without reliably running the logger or finalizer. Supporting SIGTERM would require explicit signal handlers, a defined exit code (typically 143), and platform-specific regression coverage, so I recommend keeping that as a separate follow-up rather than broadening #207.

context.log.warning("Interrupted.")
elif outcome.kind == "unexpected_error":
context.log.debug("Unexpected command exception", exc_info=True)
except BaseException: # pylint: disable=broad-exception-caught
pass

Expand Down Expand Up @@ -1045,7 +1046,7 @@ def wrapper(**kwargs: Any) -> Any:
except BaseException as exc:
if context is not None:
outcome = outcome_from_exception(click, exc)
_record_unexpected_traceback(context, outcome)
_record_lifecycle_diagnostic(context, outcome)
raise
finally:
if context is not None:
Expand Down Expand Up @@ -1399,7 +1400,7 @@ def lifecycle_aware_exit(code: int = 0) -> Any:
except BaseException as exc:
if self.context is not None:
self.outcome = outcome_from_exception(self.click, exc)
_record_unexpected_traceback(self.context, self.outcome)
_record_lifecycle_diagnostic(self.context, self.outcome)
self._finalize()
raise

Expand Down Expand Up @@ -1445,7 +1446,7 @@ def record_exception(self, exc: BaseException) -> None:
state.attached_completion = False
if self.context is not None:
self.outcome = outcome_from_exception(self.click, exc)
_record_unexpected_traceback(self.context, self.outcome)
_record_lifecycle_diagnostic(self.context, self.outcome)

def __exit__(
self,
Expand Down
4 changes: 4 additions & 0 deletions tests/test_adversarial_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,12 +277,14 @@ def main(ctx: base_cli.Context) -> None:
result = invoke(app, [], home=home)
metadata_paths = tuple((home / ".cache").rglob("run.json"))
payload = json.loads(metadata_paths[0].read_text(encoding="utf-8"))
log_text = (metadata_paths[0].parent / "logs" / "primary.log").read_text(encoding="utf-8")
temp_dir = Path(seen["temp_dir"])
temp_contents = tuple(temp_dir.iterdir()) if temp_dir.is_dir() else ()
logger_handlers = list(seen["logger"].handlers) # type: ignore[union-attr]

self.assertEqual(result.exit_code, base_cli.ExitCode.INTERRUPTED)
self.assertIn("Interrupted.", result.stderr)
self.assertIn("Interrupted.", log_text)
self.assertEqual(len(metadata_paths), 1)
self.assertEqual(payload["outcome"], "interrupted")
self.assertEqual(payload["status"], "error")
Expand Down Expand Up @@ -374,11 +376,13 @@ def main(ctx: base_cli.Context) -> None:

metadata_paths = tuple(cache.rglob("run.json"))
payload = json.loads(metadata_paths[0].read_text(encoding="utf-8"))
log_text = (metadata_paths[0].parent / "logs" / "primary.log").read_text(encoding="utf-8")
temp_dir = metadata_paths[0].parent / "tmp" / "signal-child" / payload["run_id"]
temp_contents = tuple(temp_dir.iterdir())

self.assertEqual(process.returncode, base_cli.ExitCode.INTERRUPTED, stderr)
self.assertIn("Interrupted.", stderr)
self.assertIn("Interrupted.", log_text)
self.assertEqual(len(metadata_paths), 1)
self.assertEqual(payload["outcome"], "interrupted")
self.assertEqual(payload["status"], "error")
Expand Down
5 changes: 4 additions & 1 deletion tests/test_app_run_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,11 +220,14 @@ def main(

home = Path(tmpdir)
status, stderr = _run(app, home)
_, metadata = _load_only_metadata(self, home)
metadata_path, metadata = _load_only_metadata(self, home)
log_text = (metadata_path.parent / "logs" / "primary.log").read_text(encoding="utf-8")

self.assertEqual(status, expected_code)
self.assertIn(expected_message, stderr)
self.assertNotIn("Traceback", stderr)
if expected_outcome == "interrupted":
self.assertIn("Interrupted.", log_text)
_assert_terminal_metadata(
self,
metadata,
Expand Down