Skip to content

Commit 930b94c

Browse files
authored
Fix test hang in subprocess expansion service on port bind failure (#38572)
* Fix silent test hang in subprocess expansion service on port bind failure * Formatting * Add retry when starting subprocess server. * Add sleep before retrying.
1 parent b9b9e78 commit 930b94c

2 files changed

Lines changed: 58 additions & 42 deletions

File tree

sdks/python/apache_beam/runners/portability/expansion_service_main.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ def main(argv):
5555
with fully_qualified_named_transform.FullyQualifiedNamedTransform.with_filter(
5656
known_args.fully_qualified_name_glob):
5757

58-
address = '0.0.0.0:{}'.format(known_args.port)
58+
# Bind to localhost instead of 0.0.0.0 to ensure compatibility with loopback
59+
# connections on dual-stack (IPv4/IPv6) systems.
60+
address = 'localhost:{}'.format(known_args.port)
5961
server = grpc.server(thread_pool_executor.shared_unbounded_instance())
6062
if known_args.serve_loopback_worker:
6163
beam_fn_api_pb2_grpc.add_BeamFnExternalWorkerPoolServicer_to_server(
@@ -71,9 +73,15 @@ def main(argv):
7173
artifact_service.ArtifactRetrievalService(
7274
artifact_service.BeamFilesystemHandler(None).file_reader),
7375
server)
74-
server.add_insecure_port(address)
76+
# Ensure gRPC server successfully binds. If this fails (e.g., due to port collision),
77+
# add_insecure_port returns 0. We raise an error to crash the subprocess immediately,
78+
# allowing the parent process to detect it and fail fast rather than hanging.
79+
bound_port = server.add_insecure_port(address)
80+
if not bound_port:
81+
raise RuntimeError(
82+
"Failed to bind expansion service to {}".format(address))
7583
server.start()
76-
_LOGGER.info('Listening for expansion requests at %d', known_args.port)
84+
_LOGGER.info('Listening for expansion requests at %d', bound_port)
7785

7886
def cleanup(unused_signum, unused_frame):
7987
_LOGGER.info('Shutting down expansion service.')

sdks/python/apache_beam/utils/subprocess_server.py

Lines changed: 47 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -186,45 +186,53 @@ def __exit__(self, *unused_args):
186186
self.stop()
187187

188188
def start(self):
189-
try:
190-
process, endpoint = self.start_process()
191-
wait_secs = .1
192-
channel_options = [
193-
("grpc.max_receive_message_length", -1),
194-
("grpc.max_send_message_length", -1),
195-
# Default: 20000ms (20s), increased to 10 minutes for stability
196-
("grpc.keepalive_timeout_ms", 600_000),
197-
# Default: 2, set to 0 to allow unlimited pings without data
198-
("grpc.http2.max_pings_without_data", 0),
199-
# Default: False, set to True to allow keepalive pings when no calls
200-
("grpc.keepalive_permit_without_calls", True),
201-
# Default: 2, set to 0 to allow unlimited ping strikes
202-
("grpc.http2.max_ping_strikes", 0),
203-
# Default: 0 (disabled), enable socket reuse for better handling
204-
("grpc.so_reuseport", 1),
205-
]
206-
self._grpc_channel = grpc.insecure_channel(
207-
endpoint, options=channel_options)
208-
channel_ready = grpc.channel_ready_future(self._grpc_channel)
209-
while True:
210-
if process is not None and process.poll() is not None:
211-
_LOGGER.error("Started job service with %s", process.args)
212-
raise RuntimeError(
213-
'Service failed to start up with error %s' % process.poll())
214-
try:
215-
channel_ready.result(timeout=wait_secs)
216-
break
217-
except (grpc.FutureTimeoutError, grpc.RpcError):
218-
wait_secs *= 1.2
219-
logging.log(
220-
logging.WARNING if wait_secs > 1 else logging.DEBUG,
221-
'Waiting for grpc channel to be ready at %s.',
222-
endpoint)
223-
return self._stub_class(self._grpc_channel)
224-
except: # pylint: disable=bare-except
225-
_LOGGER.exception("Error bringing up service")
226-
self.stop()
227-
raise
189+
max_attempts = 3
190+
for attempt in range(max_attempts):
191+
try:
192+
process, endpoint = self.start_process()
193+
wait_secs = .1
194+
channel_options = [
195+
("grpc.max_receive_message_length", -1),
196+
("grpc.max_send_message_length", -1),
197+
# Default: 20000ms (20s), increased to 10 minutes for stability
198+
("grpc.keepalive_timeout_ms", 600_000),
199+
# Default: 2, set to 0 to allow unlimited pings without data
200+
("grpc.http2.max_pings_without_data", 0),
201+
# Default: False, set to True to allow keepalive pings when no calls
202+
("grpc.keepalive_permit_without_calls", True),
203+
# Default: 2, set to 0 to allow unlimited ping strikes
204+
("grpc.http2.max_ping_strikes", 0),
205+
# Default: 0 (disabled), enable socket reuse for better handling
206+
("grpc.so_reuseport", 1),
207+
]
208+
self._grpc_channel = grpc.insecure_channel(
209+
endpoint, options=channel_options)
210+
channel_ready = grpc.channel_ready_future(self._grpc_channel)
211+
while True:
212+
if process is not None and process.poll() is not None:
213+
_LOGGER.error("Started job service with %s", process.args)
214+
raise RuntimeError(
215+
'Service failed to start up with error %s' % process.poll())
216+
try:
217+
channel_ready.result(timeout=wait_secs)
218+
break
219+
except (grpc.FutureTimeoutError, grpc.RpcError):
220+
wait_secs *= 1.2
221+
logging.log(
222+
logging.WARNING if wait_secs > 1 else logging.DEBUG,
223+
'Waiting for grpc channel to be ready at %s.',
224+
endpoint)
225+
return self._stub_class(self._grpc_channel)
226+
except Exception as e:
227+
_LOGGER.warning(
228+
"Error bringing up service on attempt %d: %s",
229+
attempt + 1,
230+
e,
231+
exc_info=True)
232+
self.stop()
233+
if attempt == max_attempts - 1:
234+
raise
235+
time.sleep(1)
228236

229237
def start_process(self):
230238
if self._owner_id is not None:

0 commit comments

Comments
 (0)