Skip to content

Commit 929fe29

Browse files
committed
test: diagnose worker cancellation escalation
1 parent 3a16a39 commit 929fe29

4 files changed

Lines changed: 114 additions & 20 deletions

File tree

‎.github/workflows/worker-lifecycle-compatibility.yaml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,10 @@ jobs:
100100
- uses: actions/checkout@v4
101101
with:
102102
repository: menshikovs/R-Knowledge-MCP
103-
ref: main
103+
# Pin the latest public commit inspected for the launcher consumer.
104+
# It does not currently publish tests/test_codex_launcher.py; the
105+
# pytest step remains strict so the missing consumer evidence stays visible.
106+
ref: 8f01870558263283ba197dc215d0f1ab5ad864af
104107
path: R-Knowledge-MCP
105108
- uses: actions/setup-python@v5
106109
with:

‎R/workers.R‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,7 @@ Worker <- R6::R6Class(
288288
last_diagnostics = NULL,
289289
exited_at = NULL,
290290
cancel_deadline = NULL,
291+
cancel_escalated_at = NULL,
291292

292293
start_process = function(app, fn, args, packages, timeout, supervision) {
293294
payload <- worker_prepare_payload(fn, args, packages)
@@ -399,6 +400,7 @@ Worker <- R6::R6Class(
399400
alive <- p$is_alive()
400401
if (isTRUE(self$cancel_requested) && alive &&
401402
!is.null(private$cancel_deadline) && now_seconds() >= private$cancel_deadline) {
403+
private$cancel_escalated_at <- now_seconds()
402404
private$force_terminate()
403405
alive <- p$is_alive()
404406
}
@@ -591,7 +593,10 @@ Worker <- R6::R6Class(
591593

592594
finish_cancelled = function() {
593595
if (self$state != "running") return(invisible(FALSE))
594-
private$last_diagnostics <- private$diagnostics()
596+
private$last_diagnostics <- paste0(
597+
private$diagnostics(),
598+
"; cancel_escalated=", !is.null(private$cancel_escalated_at)
599+
)
595600
if (!is.null(private$process)) try(private$process$kill_tree(), silent = TRUE)
596601
private$cleanup()
597602
private$cancel_deadline <- NULL

‎docs/WORKER_LIFECYCLE_COMPATIBILITY_REPORT.md‎

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -516,8 +516,45 @@ The report-only commit `319d4c6f6ea31bc8bd0529306f8187b7f604326e` triggered
516516
which created four jobs. Windows and macOS passed again, and the consumer job
517517
failed again on the missing public test path. At 2026-10-26 11:15:31 UTC, the
518518
Ubuntu job was still in `setup-r-dependencies`; it had not rerun the lifecycle
519-
suite. The Ubuntu escalation result therefore remains unresolved rather than
520-
confirmed as transient or reproducible.
519+
suite.
520+
521+
Commit `3a16a399e670c10a9efc99ca8556b93236a55858` triggered
522+
[run 40313778899](https://github.com/menshikovs/termr/actions/runs/40313778899),
523+
which completed all four jobs. Windows and macOS passed the shared contracts,
524+
the CRAN real-child probe and their applicable lifecycle tests. Windows
525+
reported the same two explicit Windows skips. Ubuntu passed the shared
526+
contracts and CRAN real-child probe, then failed only the lifecycle assertion
527+
at `test-worker-lifecycle.R:219`: elapsed time was 0.054 seconds versus the
528+
fixture's 0.100-second minimum. The logs contain no child-side acknowledgement
529+
or escalation-reason diagnostic. Therefore they cannot distinguish a child
530+
that exited on SIGINT from an early escalation; the prior test itself only
531+
assumed that the child ignored the interrupt. This was an inconclusive test
532+
oracle, not evidence of a worker defect. The new test fixture and private
533+
diagnostic below are pending a hosted rerun.
534+
535+
The pending correction removes the minimum-elapsed assertion. The escalation
536+
fixture now prints an acknowledgement from its interrupt handler and keeps
537+
running; the test checks that it is still alive, then checks a private
538+
monotonic timestamp recorded only when the grace-deadline branch force-kills
539+
it. Separate tests cover a prompt cleanup handler and a child released to exit
540+
naturally after cancellation was accepted. This adds no public API and changes
541+
no cancellation defaults or behavior. On the local Windows sandbox,
542+
`Rscript -e "testthat::test_local('.', filter = 'worker-lifecycle', reporter =
543+
'summary')"` passed the runnable expectations; process tests were skipped
544+
because the environment blocks processx pipes. A hosted rerun is required for
545+
the real-process assertions.
546+
547+
The consumer failure in runs 40311845330, 40312594293 and 40313778899 is an
548+
unpublished-test blocker. The current public R-Knowledge-MCP `main` is
549+
`8f01870558263283ba197dc215d0f1ab5ad864af`. The branch listing contained 34
550+
public heads; recursive tree inspection found no launcher-test file on any of
551+
them. The 25 public pull-request file lists likewise contained no
552+
`tests/test_codex_launcher.py` (or equivalent launcher test path). No public
553+
commit containing the referenced pytest tests was located, so the tests cannot
554+
be run unmodified against either package version. The workflow now pins the
555+
inspected `main` commit; it retains the strict test invocation and does not
556+
substitute unrelated tests. This consumer comparison remains unresolved until
557+
the consumer publishes its suite.
521558

522559
`actionlint` was not installed. A local `go install` attempt failed because
523560
the build network was blocked (`proxy.golang.org` connection refused) and Go's
@@ -530,19 +567,21 @@ substitute for the actual runs linked above.
530567

531568
| Platform/job | CRAN 1.0.1 and development contract | Real-child baseline probe | Lifecycle suite | Classification |
532569
|---|---|---|---|---|
533-
| Ubuntu | Run 40311845330: pass / pass (32 expectations each), probe pass. Run 40312594293 had not reached tests at 11:15:31 UTC | Pass in 40311845330 | One failure in 40311845330: escalation elapsed 0.055s, expected at least 0.1s | Unresolved cancellation/escalation behavior; repeat test pending runner progress |
534-
| Windows | Pass / pass (32 expectations each) in runs 40311845330 and 40312594293 | Pass in both runs | Pass in both; 2 explicit POSIX signal skips | Applicable Windows lifecycle behavior passed; skipped cases are unsupported on Windows |
535-
| macOS | Pass / pass (32 expectations each) in runs 40311845330 and 40312594293 | Pass in both runs | Pass in both; no skips | Applicable macOS lifecycle behavior passed |
536-
| Ubuntu R-Knowledge-MCP | Not applicable; both package installs succeeded | Not applicable | No tests ran; pytest exit 4 due missing file in runs 40311845330 and 40312594293 | Consumer comparison unresolved due missing public test suite |
570+
| Ubuntu | Pass / pass (32 expectations each) in 40313778899; CRAN probe pass | Pass in 40313778899 | One failure in 40313778899: the 0.054s early completion cannot establish graceful exit versus escalation; new diagnostic test pending | Unresolved pending synchronized rerun |
571+
| Windows | Pass / pass in 40311845330, 40312594293 and 40313778899; CRAN probe pass | Pass in all three runs | Pass in all three; 2 explicit Windows skips | Applicable Windows lifecycle behavior passed; two POSIX signal cases are unsupported on Windows |
572+
| macOS | Pass / pass in 40311845330, 40312594293 and 40313778899; CRAN probe pass | Pass in all three runs | Pass in all three; no skips | Applicable macOS lifecycle behavior passed |
573+
| Ubuntu R-Knowledge-MCP | Package installs succeeded in 40313778899; test source pinned to `8f01870558263283ba197dc215d0f1ab5ad864af` | Not applicable | No tests ran; pytest exit 4 because the pinned consumer revision lacks the requested file | Unresolved: consumer tests are not present on public branches or PRs |
537574

538575
The [R-CMD-check workflow](https://github.com/menshikovs/termr/actions/workflows/R-CMD-check.yaml)
539576
has push filters for `main`, `master` and `develop-*`, plus an unfiltered
540-
`pull_request` trigger. No pull request exists for this branch, so there is no
541-
R-CMD-check run for this head. A pull request from this feature branch will
542-
trigger its matrix: Ubuntu release, oldrel-1, R 4.1 and devel; macOS release;
543-
and Windows release. The dedicated [worker lifecycle workflow](https://github.com/menshikovs/termr/actions/workflows/worker-lifecycle-compatibility.yaml)
544-
now runs real child probes and lifecycle tests on all three operating systems,
545-
but its Ubuntu escalation failure and the consumer suite path remain open.
577+
`pull_request` trigger. At this report revision no draft pull request has been
578+
created, so there is no feature-branch R-CMD-check run yet. A draft PR from this
579+
branch triggers its matrix: Ubuntu release, oldrel-1, R 4.1 and devel; macOS
580+
release; and Windows release. The dedicated
581+
[worker lifecycle workflow](https://github.com/menshikovs/termr/actions/workflows/worker-lifecycle-compatibility.yaml)
582+
runs real child probes and lifecycle tests on all three operating systems.
583+
The Ubuntu termination reason, consumer suite availability and feature-branch
584+
R CMD check remain open.
546585

547586
## K. Breaking-change assessment
548587

‎tests/testthat/test-worker-lifecycle.R‎

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -190,16 +190,21 @@ test_that("graceful cancellation allows child cleanup while the event loop stays
190190
pilot$wait_for(function(app) !worker$is_running(), timeout = 8)
191191
expect_identical(worker$state, "cancelled")
192192
expect_true(file.exists(marker))
193+
expect_null(worker$.__enclos_env__$private$cancel_escalated_at)
194+
expect_match(worker$.__enclos_env__$private$last_diagnostics,
195+
"exit_status=0;.*cancel_escalated=FALSE")
193196
expect_gt(ticks, 0L)
194197
expect_lt(ticks, 1000L)
195198
})
196199

197-
test_that("a missed graceful interrupt escalates at its deadline", {
200+
test_that("a child that ignores SIGINT is killed at the grace deadline", {
198201
skip_on_cran()
199202
script <- tempfile("termr-unresponsive-child-", fileext = ".R")
200203
writeLines(c(
201204
'cat("READY\\n"); flush(stdout())',
202-
"repeat(tryCatch(Sys.sleep(0.02), interrupt = function(e) NULL))"
205+
'repeat(tryCatch(Sys.sleep(60), interrupt = function(e) {',
206+
' cat("INTERRUPT_HANDLED\\n"); flush(stdout())',
207+
"}))"
203208
), script)
204209
on.exit(unlink(script, force = TRUE), add = TRUE)
205210

@@ -212,12 +217,54 @@ test_that("a missed graceful interrupt escalates at its deadline", {
212217
}, add = TRUE)
213218
lifecycle_wait_for_output(pilot, worker, "READY")
214219

215-
started <- now_seconds()
216-
expect_true(worker$request_cancel(grace = 0.15))
220+
expect_true(worker$request_cancel(grace = 2))
221+
deadline <- worker$.__enclos_env__$private$cancel_deadline
222+
pilot$wait_for(function(app) "INTERRUPT_HANDLED" %in% worker$stdout, timeout = 8)
223+
expect_true(worker$is_running(), info = paste(worker$stdout, collapse = " | "))
224+
expect_true(worker$cancel_requested)
225+
226+
pilot$wait_for(function(app) !worker$is_running(), timeout = 8)
227+
private <- worker$.__enclos_env__$private
228+
expect_identical(worker$state, "cancelled")
229+
expect_true(is.numeric(private$cancel_escalated_at))
230+
expect_gte(private$cancel_escalated_at, deadline)
231+
expect_match(private$last_diagnostics, "cancel_escalated=TRUE")
232+
})
233+
234+
test_that("a natural child exit during a cancellation request is not escalated", {
235+
skip_on_cran()
236+
release <- tempfile("termr-cancel-natural-release-")
237+
script <- tempfile("termr-cancel-natural-child-", fileext = ".R")
238+
writeLines(c(
239+
'cat("READY\\n"); flush(stdout())',
240+
sprintf("release <- %s", deparse(release)),
241+
"repeat {",
242+
' tryCatch(Sys.sleep(60), interrupt = function(e) {',
243+
' cat("CANCEL_RECEIVED\\n"); flush(stdout())',
244+
" })",
245+
' if (file.exists(release)) quit(save = "no", status = 0L, runLast = FALSE)',
246+
"}"
247+
), script)
248+
on.exit(unlink(c(release, script), force = TRUE), add = TRUE)
249+
250+
application <- app(label("x"))
251+
pilot <- test_app(application, 20, 2)
252+
worker <- lifecycle_start_process(application, args = c("--vanilla", script))
253+
on.exit({
254+
if (worker$is_running()) worker$cancel(grace = 0)
255+
pilot$stop()
256+
}, add = TRUE)
257+
lifecycle_wait_for_output(pilot, worker, "READY")
258+
259+
expect_true(worker$request_cancel(grace = 5))
260+
pilot$wait_for(function(app) "CANCEL_RECEIVED" %in% worker$stdout, timeout = 8)
261+
expect_true(worker$is_running())
262+
writeLines("exit naturally", release)
217263
pilot$wait_for(function(app) !worker$is_running(), timeout = 8)
264+
private <- worker$.__enclos_env__$private
218265
expect_identical(worker$state, "cancelled")
219-
expect_gte(now_seconds() - started, 0.1)
220-
expect_lt(now_seconds() - started, 8)
266+
expect_null(private$cancel_escalated_at)
267+
expect_match(private$last_diagnostics, "exit_status=0;.*cancel_escalated=FALSE")
221268
})
222269

223270
test_that("asynchronous cancellation removes worker temporary files", {

0 commit comments

Comments
 (0)