Skip to content

fix: a log that cannot be written does not fail the request - #925

Merged
blaipr merged 1 commit into
mainfrom
fix/a-log-that-cannot-be-written-does-not-fail-the-request
Sep 17, 2026
Merged

blaipr merged 1 commit into
mainfrom
fix/a-log-that-cannot-be-written-does-not-fail-the-request

Conversation

@blaipr

@blaipr blaipr commented Sep 17, 2026

Copy link
Copy Markdown
Member

LoggerBase::update() — the file/syslog receiver — has no guard around its body. Its three siblings
all have one:

receiver guarded attached
DatabaseHandler catch (Exception) → processException() only when isLogEnabled()
MailEvent catch (Exception) → processException() only when isMailEnabled()
NotificationEvent catch (Exception) → processException() conditionally
LoggerBase nothing every request, before the install check, no flag

So it is the one receiver that always runs and the one that cannot fail safely.

Monolog's StreamHandler throws when var/syspass.log cannot be opened or appended to — a full
disk, or permissions on var/. EventDispatcher::notify() is always called after the work it
describes, so that exception propagates out of an operation that has already completed.
Router catches it at the top and turns it into a generic error response, which means: the
administrator is told a master-password rotation failed when it had already finished. That is the
one outcome that must never be ambiguous — the whole reason the rotation writes its hash inside the
transaction.

unsetAppLocales() was also skipped on that path, leaving the request's locale set.

The change

The body moves into a private writeEvent(), and update() wraps it the way the siblings do, with
a finally so the locale is restored either way.

Two deliberate differences from the siblings:

  • Throwable, not Exception — a stream failure can surface as an Error, and
    processException() accepts either.
  • processException() is safe to call from here, which is worth stating because it looks
    circular: logger() writes with a suppressed file_put_contents() and falls back to
    error_log(), so it does not come back through Monolog and cannot throw again.

Test

testAFailingLoggerDoesNotFailTheRequest makes the injected logger throw and asserts update()
returns — and that unsetAppLocales() still ran, which a plain try/catch around the call would
have missed.

Mutation-verified: reverting src/ lets the RuntimeException escape the test as an error.

Not changed

That this receiver is attached unconditionally, while the other two are config-gated, is left alone:
the file log is the one that has to work before the database and the config are usable, which is why
it is attached first. The defect was that it could not fail safely, not that it runs.

LoggerBase::update() — the file/syslog receiver — had no guard around its body, and it is
the one receiver attached on every request, before the install check and regardless of any
config flag. Its three siblings all catch and hand to processException(), and two of them
are config-gated as well.

Monolog's StreamHandler throws when var/syspass.log cannot be opened or appended to, and
notify() is always called after the work it describes, so that exception propagated out of
an operation that had already completed. Router turns it into a generic error response,
which means an administrator is told a master-password rotation failed when it had already
finished — the one outcome that must never be ambiguous. unsetAppLocales() was skipped on
that path too.

The body moves into a private writeEvent() and update() wraps it the way the siblings do,
with a finally so the locale is restored either way. Throwable rather than the siblings'
Exception, because a stream failure can surface as an Error and processException() accepts
either; and processException() is safe to call from here because logger() writes with a
suppressed file_put_contents() and falls back to error_log(), so it does not come back
through Monolog.

That this receiver is attached unconditionally is left alone: the file log is the one that
has to work before the database and the config are usable. The defect was that it could not
fail safely, not that it runs.
@blaipr
blaipr merged commit 683387b into main Sep 17, 2026
8 checks passed
@blaipr
blaipr deleted the fix/a-log-that-cannot-be-written-does-not-fail-the-request branch September 17, 2026 01:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant