Skip to content

feat: give listeners the same output channel as tasks - #31

Merged
refsz merged 1 commit into
feature/argv-execfrom
feature/output-channel
Aug 19, 2026
Merged

feat: give listeners the same output channel as tasks#31
refsz merged 1 commit into
feature/argv-execfrom
feature/output-channel

Conversation

@refsz

@refsz refsz commented Aug 19, 2026

Copy link
Copy Markdown
Owner

A listener that ran a command produced nothing at all. Not truncated, not unformatted — silent.

The cause is that there were two executor paths, and only one of them had an output:

Caller Executor Output
Task TaskRunner::createContext() built new ShellExecutor($output) visible
Listener the container's shellExecutor service, built as ShellExecutor::class with no arguments discarded

The strongest evidence is the consumer count: @shellExecutor was referenced in exactly one place in the whole codebase — as the inner of the listener executor. That service existed only for listeners, and it was the one without a destination. A composer install in a listener ran silently for minutes, a failure went unreported, and listeners in the field fell back to echo, which bypasses the redacting output and can print a secret.

Stacked on #30, which this shares the executor surface with. Merge #30 first; GitHub retargets this to main.

One place that knows where Sputnik writes

Console\OutputChannel is registered empty, filled once bin/sputnik has wrapped the output in the redacting decorator, and shared from there:

bin/sputnik → OutputChannel ← ShellExecutor (one instance, tasks and listeners)
                           ← TaskRunner (adds the per-run SputnikOutput)
                           ← listeners (inject it to write)

ShellExecutor takes the channel instead of two nullable output parameters, and TaskRunner no longer constructs its own executor but takes the container's. That is what removes the second path rather than giving it an output too.

Filling the channel at startup, not in TaskRunner::run(), is the part that matters for the case this started from: context:switch is a command, not a task, so its listeners never pass through the task runner. That is exactly where the reference project's reset-on-switch listener lives.

Listeners can now write properly:

public function __construct(private readonly OutputChannel $output) {}

public function __invoke(ContextSwitchedEvent $event): void
{
    $this->output->writeln('Switched to ' . $event->newContext);
}

Deliberately not included

Variables, templates or task execution for listeners. The missing output channel is a defect with evidence behind it; the rest has no case yet.

Verification

vendor/bin/phpunit          742 tests, 1281 assertions, OK
vendor/bin/phpstan analyse  [OK] No errors
vendor/bin/php-cs-fixer     clean
vendor/bin/rector --dry-run [OK]
mkdocs build --strict       exit 0

The proof is an end-to-end test that drives the real binary through context:switch with a listener running echo, and asserts the output appears. It is discriminating: removing the one line that fills the channel makes it fail, which I verified by mutation before committing. Four integration tests cover the channel's behaviour with a task-shaped output, with redaction, and unfilled (the command still runs, its output is on the result, nothing is printed and nothing fails), plus seven unit tests on the channel itself.

docs/listeners.md gains a Writing Output section that names why echo is the wrong tool here.

🤖 Generated with Claude Code

https://claude.ai/code/session_018CTvnzcNYmFgm2HQcm821A

A listener that ran a command produced nothing. Tasks got their executor from
TaskRunner, built with the console output; listeners got the container service,
built with no output at all - and it was the only consumer of that service. So
a composer install in a listener ran silently for minutes, its failure went
unreported, and listeners in the field fell back to echo, which bypasses the
redacting output and can print a secret.

OutputChannel is now the one place that knows where Sputnik writes. It is
registered empty, filled once bin/sputnik has the redacting output, and shared
by the executor, the tasks and the listeners. That removes the second executor
path: TaskRunner no longer constructs its own but takes the container's, so a
listener command is echoed, streams, and is masked exactly like a task command.

Filling the channel at startup rather than in TaskRunner::run() matters for the
case this started from: context:switch is a command, not a task, so its
listeners never went through the task runner.

An end-to-end test drives the real binary through a context switch and asserts
the listener's command output appears; removing the channel fill makes it fail.
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