fix(cli): bound how much log a single drain reads into memory [PC-4873] - #1844
Draft
robert-ursu wants to merge 6 commits into
Draft
robert-ursu wants to merge 6 commits into
robert-ursu wants to merge 6 commits into
Conversation
robert-ursu
force-pushed
the
feat/python-job-cancellation
branch
from
August 4, 2026 16:00
b05d195 to
1df44eb
Compare
robert-ursu
force-pushed
the
feat/bound-log-tailer-reads
branch
from
August 4, 2026 16:00
228c7c2 to
2321386
Compare
…caller
`uipath server` used to block for a whole job and leave its outcome on disk for
the caller to find. StartJob now enqueues the work and returns; the server
pushes logs while the job runs and the terminal result when it finishes.
Behaviour is a pure function of the request: a caller that supplies
`resultCallbackSocket` gets async dispatch, one that does not gets the original
blocking call, byte for byte. No handshake, no capability gate on the dispatch
path -- an older caller cannot send the field and could not serve the callback
if it did.
POST {callback}/api/python/jobs/{jobKey}/result
POST {callback}/api/python/jobs/{jobKey}/logs
The readiness ACK advertises protocolVersion + capabilities so the caller knows
whether logs will be forwarded before it decides to tail the file itself.
Execution stays serialised behind the process-wide lock: a job mutates process
globals (logging handlers, OTel providers, env, cwd), so queueing changes who
waits, not how many run.
Also fixes two pre-existing defects that made the API result meaningless:
_run_command_isolated hardcoded ExitCode 0 while click RETURNS ctx.exit(N)'s
code under standalone_mode=False, so every ConsoleLogger.error path reported
success; and the HTTP body now carries exitCode, which is the field the
un-upgraded .NET handler already reads.
Notes for review:
- Server diagnostics go to a stderr handle bound at import, NOT ConsoleLogger.
ConsoleLogger resolves sys.stdout at call time, and the runtime's interceptor
has replaced it with a writer feeding the job's execution.log -- which the
tailer then reads and posts back. With the callback down that is a
self-feeding loop.
- A 4xx from the callback is REJECTED, not UNREACHABLE: the caller is up and has
moved on, so retrying cannot help and must not trip the shutdown path.
- The log tailer holds back an unterminated tail; a handler writes the record
and only then flushes, so a poll can otherwise split one line into two
entries that cannot be rejoined.
- Logs are tailed from the file rather than captured via a handler: the
runtime's log interceptor strips every handler but its own, and
uipath-runtime ships on its own release train.
Stopping a job that is already executing is deliberately NOT in this change --
it follows on top.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI runs mypy over tests as well as src, and these tests were red: the recording fakes they pass to JobRegistry.start and JobLogTailer are not HandlerCallbacks. Neither collaborator wants the socket transport -- they want somewhere to send a result and somewhere to send logs -- so say that: JobReporter is a Protocol with those two methods, and HandlerCallback satisfies it structurally. The fakes then type-check as themselves rather than needing a cast at every call site. Worth noting the protocol immediately found a real gap: test_server_async's FakeCallback had no post_logs at all, so the tailer's calls into it were only ever working by accident of it never being exercised there. The rest is local: cast the _FakeRequest stand-ins to web.Request at the two handle_start call sites, and narrow web.Response.text (str | None) before json.loads and `in`.
Builds on async dispatch. Until now StopJob could only refuse a running job:
its body runs on a thread via asyncio.to_thread, which cannot be cancelled.
That reasoning missed a fact. run/debug/eval each drive their own event loop
with asyncio.run INSIDE the worker thread (cli_run.py:333, cli_debug.py:269,
cli_eval.py:531), so a job IS an event loop -- and an event loop can be
cancelled. The three call sites now go through `run_job_loop`, which publishes
that loop and its root task to a JobControl carried on a ContextVar.
asyncio.to_thread propagates contextvars, so no monkeypatching is needed, and
outside the server (uipath run on a terminal) it is asyncio.run verbatim.
Cancellation is cooperative, not a thread kill: the runtime's context managers
still unwind, so UiPathRuntimeContext.__exit__ still writes output.json and the
caller's file fallback still works.
The stop ladder:
1. cancel the ROOT task only, then wait STOP_GRACE_SECONDS. Cancelling every
task would land a second CancelledError inside the cleanup that writes
output.json and abort it mid-write.
2. if cleanup itself is stuck, sweep the loop and wait a shorter window.
3. otherwise return False -- a job wedged in a non-cancellable C call (a
socket read inside an LLM request) cannot be stopped, and saying so is
better than claiming a stop that did not happen.
A stopped job reports Stopped, not Faulted. The runtime records it as
FAULTED/ERROR_CancelledError because it sees a CancelledError, which is the
wrong story for a stop the caller asked for, so the result push carries an
explicit `stopped` flag that wins over the document.
Stop is also reachable now: POST /jobs/{job_key}/stop on the HTTP transport,
which carries all current traffic and previously had no way to reach the
registry at all.
Notes for review:
- _invoke_command discriminates the two CancelledErrors via Task.cancelling():
0 means the job's own loop was cancelled (an outcome, swallow it); >0 means
our awaiting task was cancelled (a shutdown, re-raise).
- StopJob takes resume_version as a trailing optional parameter rather than a
DTO: uipath-ipc ignores a surplus wire arg and defaults a missing one, so old
and new peers interoperate both ways. A suspended job resumes under the same
key, so a stop aimed at the previous run must not kill the resumed one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… when asked Two defects in the cancellation path, both found in review. JobControl.cancel() documented that it must be called at most once, but JobRegistry.stop() called it unguarded on every request. A stop followed by a force-stop escalation is ordinary, and the second delivery lands inside the runtime's cleanup finally blocks -- the ones that write output.json -- and aborts them, destroying the fallback the caller relies on. Absorbing the repeat belongs in JobControl, not in every caller. _invoke_command treated any CancelledError with cancelling() == 0 as a user-requested stop. cancelling() reports on the awaiting server task, so a CancelledError the job's own code let escape was reported as exit 143 "stopped on request" with no StopJob in sight -- a fault filed as a clean stop. Gate the classification on the control's own cancel_requested. Also corrects JobRegistry.stop()'s docstring, which still described the pre-cancellation behaviour of refusing to stop executing work.
Same CI gate as the parent commit, applied to the tests this branch adds: give the module-level events a real Event type instead of letting them infer None, annotate the recording callback against the JobReporter protocol, and cast the _FakeRequest stand-ins at the handle_stop call sites.
The tailer did f.read() with no limit, so one drain pulled everything written since the last poll — and the decoded str costs more again. Normally the 250 ms poll keeps that tiny, but a job that logs heavily between polls, a starved tailer, or the final drain after a long run could all pull a large log in at once. The same read-it-all-to-forward-it shape has OOMed pods on the handler side. Each pass now reads at most LOG_READ_CHUNK_BYTES and loops until caught up, so peak memory is the window rather than the backlog. Two cases only reachable once the read is bounded: - A chunk boundary landing mid-line: trimmed back to the last newline, which is also what keeps the decode safe, since \n never appears inside a multi-byte UTF-8 sequence. - A single line longer than the window: emitted as a fragment rather than held. Buffering it without bound would defeat the point, and stalling on it would wedge the tailer permanently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
robert-ursu
force-pushed
the
feat/python-job-cancellation
branch
from
August 19, 2026 19:13
1df44eb to
e14f4b2
Compare
robert-ursu
force-pushed
the
feat/bound-log-tailer-reads
branch
from
August 19, 2026 19:13
2321386 to
31710dd
Compare
🚨 Heads up:
|
robert-ursu
force-pushed
the
feat/python-job-cancellation
branch
from
September 24, 2026 15:26
e14f4b2 to
50e0cc7
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
JobLogTailer._draincalledf.read()with no limit, so a single pass pulled everything written since the last poll into memory — and the decodedstrcosts more again.The 250 ms poll normally keeps that tiny. It does not when:
STOP_GRACE_SECONDS),Same read-it-all-only-to-forward-it shape that has OOMed pods on the handler side (UiPath/hdens#7711).
Each pass now reads at most
LOG_READ_CHUNK_BYTES(256 KiB) and loops until caught up, so peak memory is the window, not the backlog.Two cases only reachable once the read is bounded
\nnever appears inside a multi-byte UTF-8 sequence, so cutting there can't split a character.cut == -1 → returnbecomes an infinite stall once reads are bounded).Caveats
Testing
uv run pytest tests/cli— 1457 passed, 0 failures. Two new tests for exactly the cases above: catch-up across multiple windows with a shrunkLOG_READ_CHUNK_BYTES, and a 500-byte line through a 64-byte window. The existing 14 tailer tests are unchanged and still pass — ordering, no-replay, partial-line hold-back, and final-drain behaviour are all preserved.ruff checkandruff format --checkclean;mypy --config-file pyproject.toml .— no issues in 336 source files.Jira
PC-4873