Skip to content

Commit b4050b6

Browse files
committed
timeout: wait_for_task_completion is no longer an unbounded while True
Some VM might be incredibly expensive, so leaving them in running state infinitely can drain whole project budget. So we need to take several measures: 1)Limit running time 2)Try to detect stalled boot (keywords, no log updates, etc) Signed-off-by: Denys Fedoryshchenko <denys.f@collabora.com>
1 parent 111ba45 commit b4050b6

2 files changed

Lines changed: 315 additions & 23 deletions

File tree

src/kernel_ci_cloud_labs/providers/aws_provider.py

Lines changed: 160 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,51 @@
66

77

88
# providers/aws_provider.py
9+
import os
10+
import re
11+
import time
12+
913
from kernel_ci_cloud_labs.core.base_provider import BaseProvider
1014
from kernel_ci_cloud_labs.core.logging_config import get_logger
1115
from kernel_ci_cloud_labs.core.registry import register_provider
1216

1317
logger = get_logger(__name__)
1418

1519

20+
# Kernel-side crash / stall patterns matched against each new CloudWatch
21+
# event from the guest VM console. First hit triggers an early abort of
22+
# wait_for_task_completion so the kernelci-api node is finished
23+
# incomplete/Infrastructure rather than the loop spinning for an hour on a
24+
# wedged guest.
25+
_KERNEL_CRASH_PATTERNS = tuple(
26+
re.compile(p) for p in (
27+
# Fatal traps -- guest will normally exit, but if qemu wedges it won't.
28+
r"Kernel panic - not syncing",
29+
r"\bOops\s*:",
30+
r"\bBUG\s*:",
31+
r"general protection fault",
32+
r"unable to handle kernel paging request",
33+
r"double fault",
34+
r"Internal error\s*:", # arm / arm64 die() banner
35+
# Stalls / hangs -- kernel is still scheduling but wedged.
36+
r"watchdog: BUG: soft lockup",
37+
r"soft lockup - CPU#",
38+
r"rcu_(?:sched|preempt|bh) detected stalls",
39+
r"INFO: task .* blocked for more than",
40+
)
41+
)
42+
43+
44+
def _scan_for_kernel_crash(events):
45+
"""Return the first event whose message matches a crash pattern, else None."""
46+
for event in events:
47+
message = event.get("message") or ""
48+
for pat in _KERNEL_CRASH_PATTERNS:
49+
if pat.search(message):
50+
return event
51+
return None
52+
53+
1654
@register_provider("aws")
1755
class AWSProvider(BaseProvider):
1856
"""AWS provider for running containers on Fargate."""
@@ -215,18 +253,68 @@ def wait_for_running(self, timeout=300):
215253
logger.warning("✗ Task failed to reach RUNNING state: %s", e)
216254
return False
217255

218-
def wait_for_task_completion(self):
256+
def _build_vm_log_manager(self, start_time_ms):
257+
"""Build an AWSCloudWatchManager scoped to this run's VM console group.
258+
259+
Returns None when no EC2 log group / run prefix is configured (e.g.
260+
unit tests or a deployment without the cloudwatch section); in that
261+
case wait_for_task_completion falls back to pure status polling
262+
without crash detection.
219263
"""
220-
Wait for the ECS task to complete (reach STOPPED state).
264+
cw_log_groups = self.config.get("cloudwatch", {}).get("log_groups", {}) or {}
265+
ec2_log_group = next((k for k in cw_log_groups if "/ec2/" in k), None)
266+
run_prefix = self.config.get("run_prefix")
267+
if not ec2_log_group or not run_prefix:
268+
logger.debug(
269+
"VM crash detection disabled: ec2_log_group=%s run_prefix=%s",
270+
ec2_log_group, run_prefix,
271+
)
272+
return None
273+
try:
274+
logs_client = self.auth.get_client("logs")
275+
except Exception as e: # pylint: disable=broad-exception-caught
276+
logger.warning(
277+
"Could not obtain CloudWatch logs client (%s) — "
278+
"VM crash detection disabled", e,
279+
)
280+
return None
281+
if not logs_client:
282+
logger.warning("No CloudWatch logs client — VM crash detection disabled")
283+
return None
284+
from kernel_ci_cloud_labs.auth.aws_cloudwatch_manager import ( # noqa: PLC0415
285+
AWSCloudWatchManager,
286+
)
287+
return AWSCloudWatchManager(
288+
logs_client,
289+
{},
290+
run_prefix=run_prefix,
291+
start_time_ms=start_time_ms,
292+
ec2_log_group=ec2_log_group,
293+
)
221294

222-
This uses boto3's tasks_stopped waiter which polls the task status
223-
until it transitions to STOPPED, meaning:
224-
- All containers have finished executing
225-
- All VMs have been spawned, run tests, and uploaded results to S3
226-
- The task has gracefully shut down
295+
def wait_for_task_completion(self):
296+
"""Wait for the ECS task to reach STOPPED, with crash / stall detection.
297+
298+
Polls task status until STOPPED. While waiting, tails the per-run VM
299+
console log group ({EC2_LOG_GROUP}/{run_prefix} -- written by SSM Run
300+
Command, see launch_vm.py) and aborts early on:
301+
302+
* Kernel-side crash patterns in the guest console (panic, Oops, BUG:,
303+
soft lockup, RCU stall, hung task, GP fault, kernel paging fault).
304+
The ECS task is stopped and a RuntimeError is raised so the caller
305+
finishes the kernelci-api node incomplete/Infrastructure with the
306+
matched line surfaced in error_msg.
307+
* No new VM console output for PULLAB_TASK_HANG_THRESHOLD_SEC seconds
308+
(default 600) -- silent stall, same treatment as a crash.
309+
* Overall PULLAB_TASK_WAIT_TIMEOUT_SEC seconds elapsed (default 3600)
310+
-- final safety net for whatever isn't covered above.
311+
312+
Crash detection requires both cloudwatch.log_groups (with an /ec2/
313+
group) and run_prefix in the run config; otherwise the loop falls
314+
back to plain status polling.
227315
228316
Returns:
229-
dict: Final task status including exit codes
317+
dict: Final task status including exit codes.
230318
"""
231319
if not self.task_arn:
232320
logger.error("Cannot wait for completion - no task ARN available")
@@ -235,39 +323,90 @@ def wait_for_task_completion(self):
235323
logger.info("Waiting for task to complete...")
236324
logger.debug("Task ARN: %s", self.task_arn)
237325

238-
# Poll task status with periodic INFO logging so the user sees progress
239-
import time as _time
326+
poll_interval = float(os.getenv("PULLAB_TASK_POLL_INTERVAL_SEC") or 30)
327+
log_interval = float(os.getenv("PULLAB_TASK_PROGRESS_LOG_SEC") or 120)
328+
hang_threshold = float(os.getenv("PULLAB_TASK_HANG_THRESHOLD_SEC") or 600)
329+
overall_timeout = float(os.getenv("PULLAB_TASK_WAIT_TIMEOUT_SEC") or 3600)
240330

241-
poll_interval = 30 # seconds between status checks
242-
log_interval = 120 # seconds between INFO progress messages
243-
start = _time.time()
331+
start = time.time()
332+
start_ms = int(start * 1000)
244333
last_log_time = start
334+
last_event_seen_at = start
335+
# filter_log_events startTime is inclusive; -1 so the first poll
336+
# picks up events with timestamp == start_ms.
337+
last_event_ms = start_ms - 1
338+
339+
cw_manager = self._build_vm_log_manager(start_ms)
340+
if cw_manager is None:
341+
logger.info("Wait loop: VM crash detection disabled (no log group / run_prefix)")
245342

246343
while True:
344+
elapsed = time.time() - start
345+
if elapsed > overall_timeout:
346+
logger.error(
347+
"Overall wait timeout (%ds) exceeded — stopping task",
348+
int(overall_timeout),
349+
)
350+
self.terminate_container()
351+
raise RuntimeError(
352+
f"task wait timeout exceeded after {int(elapsed)}s"
353+
)
354+
247355
status = self.get_task_status()
248356
if not status:
249357
logger.warning("Could not retrieve task status, retrying...")
250-
_time.sleep(poll_interval)
358+
time.sleep(poll_interval)
251359
continue
252360

253361
task_status = status.get("status", "UNKNOWN")
254-
elapsed = int(_time.time() - start)
255362

256363
if task_status == "STOPPED":
257-
logger.info("✓ Task completed (elapsed: %dm %ds)", elapsed // 60, elapsed % 60)
364+
logger.info(
365+
"✓ Task completed (elapsed: %dm %ds)",
366+
int(elapsed) // 60, int(elapsed) % 60,
367+
)
258368
break
259369

260-
now = _time.time()
370+
# Tail the VM console group for crash patterns / progress.
371+
if cw_manager is not None:
372+
new_events = cw_manager.get_logs_with_filter(
373+
start_time=last_event_ms + 1
374+
) or []
375+
if new_events:
376+
last_event_seen_at = time.time()
377+
for ev in new_events:
378+
ts = ev.get("timestamp", 0)
379+
if ts > last_event_ms:
380+
last_event_ms = ts
381+
hit = _scan_for_kernel_crash(new_events)
382+
if hit:
383+
msg = (hit.get("message") or "").strip()[:300]
384+
logger.error(
385+
"Kernel crash/stall in VM console (stream=%s): %s",
386+
hit.get("logStreamName", "?"), msg,
387+
)
388+
self.terminate_container()
389+
raise RuntimeError(f"kernel crash detected in VM: {msg}")
390+
elif (time.time() - last_event_seen_at) > hang_threshold:
391+
logger.error(
392+
"No VM console output for %ds (hang threshold %ds) — stopping task",
393+
int(time.time() - last_event_seen_at),
394+
int(hang_threshold),
395+
)
396+
self.terminate_container()
397+
raise RuntimeError(
398+
f"no VM console output for {int(hang_threshold)}s"
399+
)
400+
401+
now = time.time()
261402
if now - last_log_time >= log_interval:
262403
last_log_time = now
263404
logger.info(
264405
"Task still running... (status: %s, elapsed: %dm %ds)",
265-
task_status,
266-
elapsed // 60,
267-
elapsed % 60,
406+
task_status, int(elapsed) // 60, int(elapsed) % 60,
268407
)
269408

270-
_time.sleep(poll_interval)
409+
time.sleep(poll_interval)
271410

272411
# Get final status to check exit codes
273412
final_status = self.get_task_status()

0 commit comments

Comments
 (0)