Skip to content

fix: event listeners cannot constructor-inject a published OpenRegister interface - #228

Open
rubenvdlinde wants to merge 3 commits into
developmentfrom
fix/listeners-resolve-objectservice-lazily
Open

fix: event listeners cannot constructor-inject a published OpenRegister interface#228
rubenvdlinde wants to merge 3 commits into
developmentfrom
fix/listeners-resolve-objectservice-lazily

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

An OpenBuild listener was killing other apps' requests

Measured on the dev instance 2026-08-16. An ObjectCreatedEvent raised while Hermiq was persisting a chat conversation aborted the entire chat turn:

Could not resolve OCA\OpenRegister\Contract\ObjectServiceInterface!
  #3 AutomationApprovalTriggerListener.php
  #5 ServiceEventListener->handle(OCA\OpenRegister\Event\ObjectCreatedEvent)
  → ChatController.php:273 "Failed to send message"

A Hermiq request, ended by an OpenBuild listener, over an OpenRegister event. Three apps deep, and none of their authors would think to look there.

Why the alias could never have worked

ADR-084 has consumers type-hint the published interface and bind it with registerServiceAlias() in their own composition root. This app does exactly that (Application.php:110), and it is correct — for controllers and services, which are built from the app container.

Listeners are not. Nextcloud's OC\EventDispatcher\ServiceEventListener resolves the listener from the server container, and says so in its own source:

// TODO: fetch from the app containers, otherwise any custom services,
$this->service = $this->container->get($this->class);

The server container has never seen this app's alias. This is a structural limit of the ADR-084 pattern at the listener boundary, not a typo — all three listeners that injected the interface were dead the same way, each since it was migrated.

The fix resolves the CONCRETE class, and that distinction is the whole repair

An earlier draft of this change resolved ObjectServiceInterface::class through the injected container. That fails identically — the container a listener receives is the server container — and would only have moved the error later. Nextcloud autowires concrete classes across apps (this app's own composition-root comment says so), so Service\ObjectService::class resolves where the alias cannot.

The declared type stays the published contract. ObjectService implements it, so every call site and test still sees only the ADR-084 interface; the concrete name appears once per listener at the container boundary — the same shape DocuDesk already uses in DocumentObjectServiceResolver. The other OpenRegister dependencies in these constructors are concrete classes and were always fine.

Tests

Each listener now receives a container stub returning the same ObjectServiceInterface mock. Mocks and assertions unchanged — only the delivery route.

⚠️ Not verified by the unit suite. openbuild's PHPUnit bootstrap fails in-container with Interface "OCA\OpenRegister\Contract\ObjectEntityInterface" not found from tests/stubs/openregister-stubs.php. That is pre-existing and confirmed as such — the identical failure occurs with this branch's own untouched test files. It needs its own fix and is not this change.

Worth a fleet check

Any app that migrated a listener to a published OR interface has this bug, and it is silent until the event fires. A sweep of lib/Listener/** for private readonly *Interface from OpenRegister\Contract found these three; other repos should be checked the same way.

An OpenBuild listener was killing OTHER apps' requests.

Measured 2026-08-16: an ObjectCreatedEvent raised while Hermiq persisted a chat
conversation aborted the entire chat turn with

  Could not resolve OCA\OpenRegister\Contract\ObjectServiceInterface!
    #3 AutomationApprovalTriggerListener.php
    #5 ServiceEventListener->handle(OCA\OpenRegister\Event\ObjectCreatedEvent)
    -> ChatController.php:273 "Failed to send message"

A Hermiq request, ended by an OpenBuild listener, over an OpenRegister event --
which is three apps deep and none of their authors would think to look there.

WHY THE ALIAS COULD NEVER WORK. ADR-084 has consumers type-hint the published
interface and bind it with registerServiceAlias() in their own composition root.
This app does exactly that (Application::register():110), and it is correct for
controllers and services because those are built from the APP container.

Listeners are not. Nextcloud's OC\EventDispatcher\ServiceEventListener resolves
the listener class from the SERVER container, and says so in its own source:

  // TODO: fetch from the app containers, otherwise any custom services,
  $this->service = $this->container->get($this->class);

The server container has never seen this app's alias, so the constructor
parameter could not be built. This is a structural limit of the ADR-084 pattern
at the listener boundary, not a typo -- all THREE listeners that injected the
interface were dead the same way (ApprovalOutcomeListener,
AutomationApprovalTriggerListener, DocumentGenerationListener), and each had
been dead since it was migrated to the interface.

THE FIX RESOLVES THE CONCRETE CLASS, AND THAT DISTINCTION IS THE WHOLE REPAIR.
An earlier draft of this commit resolved ObjectServiceInterface::class through
the injected container -- which fails identically, because the container a
listener receives IS the server container. It would only have moved the error
later. Nextcloud autowires concrete classes across apps (this app's own
composition-root comment says so), so Service\ObjectService::class resolves
where the alias cannot.

The DECLARED TYPE stays the published contract: ObjectService implements it, so
every call site and every test still sees only the ADR-084 interface. The
concrete name appears once per listener, at the container boundary -- the same
shape DocuDesk already uses in DocumentObjectServiceResolver. The other
OpenRegister dependencies in these constructors are concrete classes and were
always fine; only the interface needed this.

Tests updated to hand each listener a container stub returning the same
ObjectServiceInterface mock. The mock and the assertions are unchanged: only the
delivery route is.

NOT VERIFIED BY THE UNIT SUITE. openbuild's PHPUnit bootstrap fails in this
container with `Interface "OCA\OpenRegister\Contract\ObjectEntityInterface" not
found` from tests/stubs/openregister-stubs.php. That is PRE-EXISTING and was
confirmed as such: the identical failure occurs with the branch's own untouched
test files. It needs its own fix and is not this change.
@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Now verified live — correcting the caveat in the description

The description says this was deployed but not demonstrated. It has since been demonstrated on the dev instance.

The exact turn that crashed, re-run against the fix:

before after
reply Could not resolve OCA\OpenRegister\Contract\ObjectServiceInterface! Class can not be instantiated OK
error the DI failure None
Could not resolve ...ObjectServiceInterface in nextcloud.log present, aborting the turn at ChatController.php:273 0 occurrences
openbuild listener error entries the abort 0

The turn creates a conversation object, so it raises the ObjectCreatedEvent that previously could not construct the listener — the same path, not a lighter one.

⚠️ The wall time on that run was 525 s and means nothing. The host was at load 81–101 throughout from an unrelated fleet sweep in another session (a full-filesystem bfs, concurrent composer installs, a playwright run). This is a functional verification only; no timing figure from this window should be quoted.

The other caveat in the description still stands: the unit suite still cannot run in this container because of the pre-existing ObjectEntityInterface stub failure in tests/bootstrap.php, which reproduces on untouched test files. That remains its own fix.

@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ 1e93864

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
check-gitignore
check-nc-floor
format
composer ✅ 106/106
npm ✅ 626/626
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-16 20:35 UTC

Download the full PDF report from the workflow artifacts.

… was removed

`PHPDoc tag @PARAM references unknown parameter: $objectService` — the one
error reddening `PHP Quality (phpstan)`, a job `development` passes, so it
was this branch's only real regression.

The constructor swapped `ObjectServiceInterface $objectService` for
`ContainerInterface $container` when the resolution moved to the lazy
objectService() accessor, but the docblock kept the old tag. The two sibling
listeners in this same change (ApprovalOutcomeListener,
DocumentGenerationListener) were already updated — this one was missed, which
is why phpstan reported exactly one error rather than three.

Documentation-only: no behaviour change.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ 7b233d4

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
check-gitignore
check-nc-floor
format
composer ✅ 106/106
npm ✅ 626/626
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright
Hydra gates

Quality workflow — 2026-08-16 23:58 UTC

Download the full PDF report from the workflow artifacts.

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.

2 participants