@@ -35,6 +35,35 @@ def log_not(msg): # pylint: disable=unused-argument
3535 # Info messages only appear in VM CloudWatch logs, not container log
3636
3737
38+ # Kernel-side fatal/near-fatal markers we scan captured console buffers for.
39+ # Hit on any of these gets logged loudly and stamped into the S3 object
40+ # metadata so downstream consumers (KCIDB submitter, triage tooling) can
41+ # flag the run without re-parsing the log. Patterns are case-sensitive
42+ # substrings — kernel prints these verbatim.
43+ PANIC_PATTERNS = (
44+ "Kernel panic - not syncing" ,
45+ "Oops: " ,
46+ "BUG: " ,
47+ "Call Trace:" ,
48+ "general protection fault" ,
49+ "Unable to handle kernel" ,
50+ "double fault" ,
51+ "watchdog: BUG: soft lockup" ,
52+ "rcu_sched detected stalls" ,
53+ "page fault in interrupt" ,
54+ )
55+
56+
57+ def _scan_for_panic (text ):
58+ """Return the first PANIC_PATTERNS substring found in text, or None."""
59+ if not text :
60+ return None
61+ for pattern in PANIC_PATTERNS :
62+ if pattern in text :
63+ return pattern
64+ return None
65+
66+
3867class VMLauncher :
3968 """Launch and manage EC2 instances with multi-run test support."""
4069
@@ -365,16 +394,23 @@ def check_test_result(self):
365394 def capture_console_output (self , reason = "cleanup" ):
366395 """Fetch EC2 serial console output (kernel boot log) and upload to S3.
367396
368- Safe to call multiple times: subsequent calls will overwrite the S3
369- object with a (typically larger) snapshot. Once we have successfully
370- captured a non-empty buffer, later calls are skipped — except when
371- forced via a different `reason` from the SSM-failure path, where the
372- most recent state of the buffer is more interesting than an earlier
373- capture taken before the failure was visible.
397+ Re-entrancy rules:
398+ * "cleanup" is the best-effort pre-terminate pass. It's skipped if
399+ a previous call already uploaded — there's nothing new to fetch
400+ while the VM is still running and the buffer only grows.
401+ * "ssm-failure" and "post-terminate" always run. The first catches
402+ a panic visible mid-run; the second catches the flushed final
403+ buffer that EC2 only finalizes after shutdown — typically the
404+ only place an early-boot panic actually shows up. Both can
405+ overwrite an earlier (smaller) capture.
406+
407+ Also scans the scrubbed output for PANIC_PATTERNS and stamps the
408+ result into the S3 object metadata so triage tooling can flag a run
409+ without re-reading the log.
374410
375411 Args:
376412 reason: Free-text label for the call site (logged for diagnostics).
377- Currently used values: "cleanup", "ssm-failure".
413+ Currently used values: "cleanup", "ssm-failure", "post-terminate" .
378414 """
379415 if not self .instance_id :
380416 return
@@ -411,6 +447,15 @@ def capture_console_output(self, reason="cleanup"):
411447 log_not (f" Console scrub redacted: { summary } " )
412448 output = scrubbed
413449
450+ # Panic scan runs on the scrubbed buffer so the pattern we log can't
451+ # re-leak a token the scrubber just redacted.
452+ panic_match = _scan_for_panic (output )
453+ if panic_match :
454+ log_error (
455+ f"⚠ Kernel panic indicator in console buffer "
456+ f"(instance={ self .instance_id } , marker={ panic_match !r} , reason={ reason } )"
457+ )
458+
414459 s3_key = f"{ self .run_prefix } /test_{ self .test } /output/{ self .instance_id } /console-output.log"
415460 try :
416461 self .s3 .put_object (
@@ -423,24 +468,86 @@ def capture_console_output(self, reason="cleanup"):
423468 # Records that the buffer passed through the scrubber, so an
424469 # operator inspecting the object knows it's not raw.
425470 "scrubbed" : "v1" ,
471+ "panic-detected" : "true" if panic_match else "false" ,
426472 },
427473 )
428474 log_not (f"✓ Console output uploaded ({ len (output )} bytes) to s3://{ self .s3_bucket } /{ s3_key } " )
429475 self ._console_captured = True
430476 except Exception as e :
431477 log_not (f" Failed to upload console output: { e } " )
432478
433- def cleanup (self ):
434- """Capture console output, then terminate instance."""
435- self .capture_console_output (reason = "cleanup" )
479+ def _wait_for_terminated (self , timeout = 90 ):
480+ """Poll describe_instances until the VM reaches a terminal state.
436481
437- if self .instance_id :
438- log_not (f"\n === Terminating instance { self .instance_id } ===" )
482+ EC2 only finalizes the serial-console buffer at shutdown; a
483+ get_console_output call against a still-running short-lived VM
484+ often returns empty because the async mirror hasn't caught up. By
485+ waiting for ``terminated``/``stopped`` we can re-fetch and get the
486+ buffer that includes early-boot output and any panic on shutdown.
487+
488+ Bounded by ``timeout`` so a stuck instance can't pin the pipeline.
489+ Returns True if a terminal state was observed, False on timeout or
490+ API error (caller proceeds either way).
491+ """
492+ if not self .instance_id :
493+ return False
494+ log_not (f"Waiting up to { timeout } s for instance { self .instance_id } to reach terminated/stopped state..." )
495+ start = time .time ()
496+ while time .time () - start < timeout :
439497 try :
440- self .ec2 .terminate_instances (InstanceIds = [self .instance_id ])
441- log_not ("✓ Instance terminated" )
498+ resp = self .ec2 .describe_instances (InstanceIds = [self .instance_id ])
499+ reservations = resp .get ("Reservations" , [])
500+ if not reservations or not reservations [0 ].get ("Instances" ):
501+ # Instance metadata aged out — treat as terminal.
502+ return True
503+ state = reservations [0 ]["Instances" ][0 ]["State" ]["Name" ]
504+ if state in ("terminated" , "stopped" ):
505+ log_not (f"✓ Instance state: { state } (after { int (time .time () - start )} s)" )
506+ return True
442507 except Exception as e :
443- log_not (f"Error terminating instance: { e } " )
508+ log_not (f" describe_instances error: { e } (continuing without wait)" )
509+ return False
510+ time .sleep (5 )
511+ log_not (f"⚠ Timed out waiting for instance to terminate after { timeout } s" )
512+ return False
513+
514+ def cleanup (self ):
515+ """Hybrid console capture around instance termination.
516+
517+ Two captures bracket the terminate call:
518+
519+ 1. Pre-terminate (``reason="cleanup"``) — best-effort while the VM
520+ is still alive. Often empty for short-lived VMs (EC2's mirror
521+ lags by minutes), but on a longer run this is the only way to
522+ grab the buffer if termination later hangs.
523+ 2. Post-terminate (``reason="post-terminate"``) — after shutdown
524+ EC2 finalizes and preserves the buffer for ~1h. This is the
525+ pass that reliably catches the boot log, kernel panics, and
526+ any shutdown-time oops.
527+
528+ The capture path scans for panic markers and stamps metadata on
529+ whichever upload wins (post-terminate overwrites cleanup).
530+ """
531+ # Best-effort live capture. Skipped silently inside the helper if a
532+ # prior ssm-failure capture already uploaded.
533+ self .capture_console_output (reason = "cleanup" )
534+
535+ if not self .instance_id :
536+ return
537+
538+ log_not (f"\n === Terminating instance { self .instance_id } ===" )
539+ try :
540+ self .ec2 .terminate_instances (InstanceIds = [self .instance_id ])
541+ log_not ("✓ Termination requested" )
542+ except Exception as e :
543+ log_not (f"Error terminating instance: { e } " )
544+ return
545+
546+ # Wait for the VM to actually wind down, then grab the flushed
547+ # buffer. This is where an early-boot panic that never made it into
548+ # the live mirror finally becomes visible.
549+ self ._wait_for_terminated (timeout = 90 )
550+ self .capture_console_output (reason = "post-terminate" )
444551
445552
446553def launch_vms_from_config ():
0 commit comments