Skip to content

bug: configure_logger closes consumer-owned handlers and forces propagate=False #387

Description

@codeforester

Problem

configure_logger() takes unconditional ownership of the process-global logger
base_cli.<cli_name>: it closes and removes every pre-existing handler, forces the level to
DEBUG, and sets propagate = False (lib/python/base_cli/logging.py:51-56):

logger = logging.getLogger(f"base_cli.{cli_name}")
logger.setLevel(logging.DEBUG)
logger.propagate = False
for handler in list(logger.handlers):
    handler.close()
    logger.removeHandler(handler)

Context._cleanup_resources() then closes and removes all handlers again at teardown
(lib/python/base_cli/context.py:158-174). Neither site distinguishes invocation-owned handlers
from handlers a host application or consumer attached, because no ownership is recorded.

This breaks three legitimate integrations:

  • an application that embeds a base-cli CLI and routes base_cli.* records to its own aggregator;
  • a consumer that attaches a handler to ship lifecycle logs to syslog/journald/OTLP;
  • any logging.config.dictConfig() setup that configures base_cli loggers, since
    propagate = False also cuts off root-logger handlers.

Closing a handler the framework did not open is the sharper half: the consumer's stream is closed
out from under it, not merely detached.

#341 ("preserve invocation-owned log handlers across nested runs") fixed the nested-run_app case.
This is the adjacent, still-open case of handlers base-cli never owned; there is no ownership
marking in the code today.

Verified evidence

Reviewed 2026-09-30 at a58ec109349fa3f3d03eae5b0de078b39ea361a2 (macOS, Python 3.14.6).

consumer_logger = logging.getLogger("base_cli.mytool")
sink = logging.StreamHandler(sys.stdout); sink.set_name("consumer-sink")
consumer_logger.addHandler(sink)
# before: ['consumer-sink'] propagate= True
returned = configure_logger("mytool", None, False)
# after : [None]            propagate= False
# consumer handler still attached: False

Confirmed there is no ownership tracking at either removal site:

$ grep -rn "removeHandler\|handlers" lib/python/base_cli/logging.py lib/python/base_cli/context.py
logging.py:54:    for handler in list(logger.handlers):
logging.py:56:        logger.removeHandler(handler)
context.py:158:        for handler in list(self.log.handlers):
context.py:168:                self.log.removeHandler(handler)
context.py:172:                    self.log.handlers.remove(handler)

Proposal

  1. Mark handlers the lifecycle creates (an attribute or a _BaseCliHandler mixin) and have both
    configure_logger() and Context._cleanup_resources() remove and close only marked
    handlers. Leave foreign handlers attached and open.
  2. Stop forcing propagate = False unconditionally. Make it a documented, overridable policy —
    the reason it exists (avoiding duplicate output through the root logger) is real, but it should
    be a choice the consumer can make. A configure_logger(..., propagate=None) tri-state, or an
    App(logger_policy=...) option.
  3. Do not call setLevel() on a logger a consumer configured; set levels on base-cli's own
    handlers, which is where the debug/quiet policy already lives.
  4. Document the logger-ownership contract in docs/integrations.md: which logger name base-cli
    owns, what it does to it, and how to attach a handler that survives.

Acceptance criteria

  • A handler attached to base_cli.<name> before an invocation is still attached and still open
    after the invocation completes.
  • logging.config.dictConfig()-configured base_cli handlers continue to receive records.
  • No duplicate records reach stderr in the default configuration (the behaviour propagate = False
    protects today) — verified by test.
  • The nested-run_app guarantee from bug: preserve invocation-owned log handlers across nested runs #341 still holds.
  • docs/integrations.md documents the ownership contract.

Non-goals

  • Do not rename the base_cli.<cli_name> logger.
  • Do not change the default human or JSON log formats.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area: runtimeRuntime, lifecycle, execution, or process-boundary ownership.bugSomething is not working

Type

No type

Projects

  • Status
    Backlog

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions