feat(agent): stream package operation output over per-operation event channel - #1914
Conversation
Implementation notesArchitecture (
Pipe security
Interop
Frame lifecycle
Executor changes
Degradation
Tunables
Tests
Validation
|
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds a per-operation event channel that clients can connect to (Windows named pipe) to receive status transitions and optionally streamed stdout/stderr.
Changes:
- Open and advertise an event channel on operation submission; reuse the same descriptor for idempotent resubmissions.
- Track and emit event frames on status transitions and operation completion.
- Stream captured stdout/stderr chunks to the event channel while still retaining a bounded diagnostic tail in
ExecutionOutput.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| devolutions-agent/src/broker/server/mod.rs | Opens per-operation event channels on Windows and adds event-channel-focused tests. |
| devolutions-agent/src/broker/operation_tracker.rs | Stores event sink/descriptor per operation and emits frames on status changes and terminal completion. |
| devolutions-agent/src/broker/mod.rs | Exposes the new event_channel module. |
| devolutions-agent/src/broker/executor/windows/process.rs | Splits stdout/stderr capture and forwards raw chunks to the event sink while keeping a bounded tail. |
| devolutions-agent/src/broker/executor/windows/mod.rs | Updates create_process calls to use OutputCapture and pass the event sink. |
| devolutions-agent/src/broker/executor/mod.rs | Adds event_sink to ExecutionContext and emits dry-run output via the sink when enabled. |
| devolutions-agent/src/broker/event_channel.rs | Implements the event channel queue/sink and Windows named-pipe writer plus unit/integration tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
687e202 to
63ff80c
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
devolutions-agent/src/broker/server/mod.rs:272
registerpublishes the operation withevent_channel: Nonebefore this lookup and the later pipe creation/update. Because the broker serves connections concurrently (broker/pipe.rs:75-101), a simultaneous idempotent retry can observe the indexed operation in this initialization window and returnevent_channel: None, even though the first request installs the channel moments later. Make channel initialization part of the atomic registration path, or represent initialization explicitly and wait for it before returning a retry response.
let mut event_channel = self
.tracker
.get(&operation_id)
.and_then(|operation| operation.event_channel);
devolutions-agent/src/broker/operation_tracker.rs:235
- Repeated cancellation requests while the operation is already
Cancelingenqueue anotherStatusUpdatedeven though no status transition occurred. Besides producing spurious protocol notifications, these control frames are not covered by the output byte budget, so idempotent cancel traffic can grow the supposedly bounded queue. Notify only when the status actually changes toCanceling.
if let Some(sink) = &op.event_sink {
sink.status_updated();
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
devolutions-agent/src/broker/event_channel.rs:500
- A connected client that stops reading can leave
write_allpending indefinitely once the pipe buffer fills. The no-client and post-finish paths are timed out, but this path is not, so each such connection can retain its writer task, pipe handle, and buffered queue forever; repeated operations can exhaust service resources. Apply a bounded write/session timeout and abandon the sink when it expires.
while let Some(frame) = sink.next_frame().await {
if let Err(error) = write_frame(&mut server, &frame).await {
devolutions-agent/src/broker/operation_tracker.rs:161
- A cancellation can race between
run_plan's initial token check and the process-started callback. If the tracker is alreadyCanceling, this method overwrites it withRunningand emits a misleading status notification before the terminal update. Recordstarted_at, but only transition and notify when the current status isStarting.
if let Some(sink) = &op.event_sink {
sink.status_updated();
devolutions-agent/src/broker/operation_tracker.rs:235
Cancelingis non-terminal, so every idempotent cancel retry enters this block and emits anotherStatusUpdatedeven though no transition occurred. Besides violating the channel's transition semantics, these unbudgeted control frames can grow the queue under repeated retries. Only perform the transition and notification when the operation is not alreadyCanceling.
if let Some(sink) = &op.event_sink {
sink.status_updated();
}
e4d938a to
66edf00
Compare
… channel For each executed package operation, the broker now creates a dedicated local named pipe (Devolutions.Now.PackageBroker.Operation.<operation-id>) before returning the execution response and advertises it via the event_channel field of the operation submission. The channel streams one-way server-to-client event frames per the event-channel protocol v1.0 (now-policy-api 0.3): HELLO on connect, STDOUT/STDERR data frames when CaptureOutput is requested, STATUS_UPDATED on every status transition, and FINISH when the operation reaches a terminal status. Output is chunked without splitting UTF-8 characters across frames. Slow or absent clients never stall the operation: data is dropped past a bounded per-stream budget and reported via overflow frames. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
- Create the event pipe duplex (client still read-only via DACL) so the post-Finish drain read actually blocks until the client closes its end, instead of erroring immediately on an outbound-only pipe. - Drop the per-frame flush: named pipe writes go straight to the kernel buffer and tokio's named pipe flush is a no-op. - Take the event sink out of the tracked operation on terminal transitions so queued frames are released once the writer task is done, rather than living until result eviction. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…readers Raise the per-stream buffer budget to 1 MiB (stdout and stderr each) and switch overflow handling to drop-oldest semantics: when a new chunk does not fit, the oldest queued data frames of the same stream are evicted and replaced in place by an overflow frame accounting the skipped bytes (adjacent overflow markers merge), so clients always receive the most recent output. Treat a broken event pipe as normal client teardown: the writer task now abandons the queue on any write failure, logging a single debug message and turning all further sink calls into silent no-ops, releasing buffered memory immediately. The operation and the child process are never affected; final state remains available via QueryStatus. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Address review feedback on the event channel: - Apply a bounded deadline (30 s) to each frame write so a connected client that stops reading cannot pin the writer task, pipe handle, and queued data forever once the kernel pipe buffer fills; on expiry the sink is abandoned like on a disconnect. - Account dropped bytes in a per-stream pending-overflow counter instead of inserting overflow frames into the queue on every eviction. The counter is reported lazily as a single overflow frame right before the next data frame of that stream (or before Finish), so evictions strictly shrink the queue and gaps coalesce even when stdout and stderr output interleaves. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
66edf00 to
0d740a9
Compare
f413772
into
master
Clients of the Devolutions Agent package broker now receive live stdout/stderr output and instant status-change notifications while package operations run.
For each executed operation, the broker opens a dedicated per-operation event channel (a local named pipe advertised in the operation submission) implementing the event-channel protocol v1.0. Connected clients get a HELLO frame, real-time STDOUT/STDERR data frames (when output capture is requested), a STATUS_UPDATED notification on every status transition, and a FINISH frame when the operation completes.
This restores the output visibility that was removed by the API v0.3 upgrade and improves on it: instead of post-hoc capture returned after the operation ends, output now streams in real time. Slow or absent clients never stall or fail the operation.
Stacked on #1913 (operation cancellation); base branch is
vnikonov-devolutions-dgw-agent-broker-cancellation.Issue: DGW-438