Skip to content

Commit 694a75f

Browse files
authored
fix: print "Listening on" only after the socket is bound (#4400) (#4413)
* fix: print "Listening on" only after the socket is bound (#4400) Emit the "Listening on ..." startup message after startServer()/ startPipeServer() returns rather than before, so the announced URL is guaranteed to be accepting connections. httpuv binds synchronously, so by the time the handle is returned the port is live. Previously the message could be printed one statement before the bind, leaving a window (widened under load) where anything scraping the line as a readiness signal (shinytest2, log watchers) could reach a not-yet-bound port. * docs: credit @nbenn in NEWS entry for #4400
1 parent 107747a commit 694a75f

3 files changed

Lines changed: 70 additions & 5 deletions

File tree

NEWS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
* `{watcher}` is now a required dependency and is always used for autoreload file watching, so it no longer needs to be installed separately. The legacy polling-based file watcher has been removed. (#4403)
44

5+
* The `Listening on http://…` startup message is now printed only after the listening socket has been bound, so the announced URL is guaranteed to be accepting connections. Previously the message could be emitted just before the bind, leaving a window (widened under load) where the advertised port refused connections. (thanks @nbenn, #4400)
6+
57
# shiny 1.14.0
68

79
## New features

R/server.R

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -456,25 +456,31 @@ startHttpuvApp <- function(appObj, port, host, quiet) {
456456
)
457457

458458
if (is.numeric(port) || is.integer(port)) {
459+
# `startServer()` binds the socket synchronously, so only announce the URL
460+
# after it returns. Otherwise the "Listening on" line (used as a readiness
461+
# signal by shinytest2 and log watchers) can advertise a port that is not
462+
# yet accepting connections (#4400).
463+
server <- startServer(host, port, httpuvApp)
459464
if (!quiet) {
460465
hostString <- host
461466
if (httpuv::ipFamily(host) == 6L)
462467
hostString <- paste0("[", hostString, "]")
463468
message('\n', 'Listening on http://', hostString, ':', port)
464469
}
465-
return(startServer(host, port, httpuvApp))
470+
return(server)
466471
} else if (is.character(port)) {
467-
if (!quiet) {
468-
message('\n', 'Listening on domain socket ', port)
469-
}
470472
mask <- attr(port, 'mask')
471473
if (is.null(mask)) {
472474
stop("`port` is not a valid domain socket (missing `mask` attribute). ",
473475
"Note that if you're using the default `host` + `port` ",
474476
"configuration (and not domain sockets), then `port` must ",
475477
"be numeric, not a string.")
476478
}
477-
return(startPipeServer(port, mask, httpuvApp))
479+
server <- startPipeServer(port, mask, httpuvApp)
480+
if (!quiet) {
481+
message('\n', 'Listening on domain socket ', port)
482+
}
483+
return(server)
478484
}
479485
}
480486

tests/testthat/test-server.R

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
test_that("startHttpuvApp() announces the URL only after the socket is bound (#4400)", {
2+
# The "Listening on ..." message is used as a readiness signal (e.g. by
3+
# shinytest2 and log watchers), so it must not be emitted until httpuv has
4+
# actually bound the listening socket. Here we mock the (synchronous) bind and
5+
# confirm the message is printed *after* it returns, never before.
6+
withr::defer(handlerManager$clear())
7+
8+
app <- shinyApp(fluidPage(), function(input, output, session) {})
9+
10+
bound <- FALSE
11+
bound_when_announced <- NULL
12+
13+
# Replace httpuv's startServer so no real socket is opened; record the bind.
14+
local_mocked_bindings(
15+
startServer = function(host, port, app, ...) {
16+
bound <<- TRUE
17+
structure(list(), class = "mock_server")
18+
}
19+
)
20+
21+
server <- withCallingHandlers(
22+
startHttpuvApp(app, port = 3839L, host = "127.0.0.1", quiet = FALSE),
23+
message = function(m) {
24+
if (grepl("Listening on", conditionMessage(m), fixed = TRUE)) {
25+
bound_when_announced <<- bound
26+
}
27+
invokeRestart("muffleMessage")
28+
}
29+
)
30+
31+
# The bind was attempted and its return value is passed through.
32+
expect_true(bound)
33+
expect_s3_class(server, "mock_server")
34+
35+
# The "Listening on" line was seen, and only after the bind had returned.
36+
expect_true(isTRUE(bound_when_announced))
37+
})
38+
39+
test_that("startHttpuvApp() with quiet = TRUE binds without announcing (#4400)", {
40+
withr::defer(handlerManager$clear())
41+
42+
app <- shinyApp(fluidPage(), function(input, output, session) {})
43+
44+
bound <- FALSE
45+
local_mocked_bindings(
46+
startServer = function(host, port, app, ...) {
47+
bound <<- TRUE
48+
structure(list(), class = "mock_server")
49+
}
50+
)
51+
52+
expect_no_message(
53+
server <- startHttpuvApp(app, port = 3839L, host = "127.0.0.1", quiet = TRUE)
54+
)
55+
expect_true(bound)
56+
expect_s3_class(server, "mock_server")
57+
})

0 commit comments

Comments
 (0)