]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Test] functional: centralize dummy helper readiness barrier
authorVsevolod Stakhov <vsevolod@rspamd.com>
Sun, 31 May 2026 19:48:45 +0000 (20:48 +0100)
committerVsevolod Stakhov <vsevolod@rspamd.com>
Sun, 31 May 2026 19:48:45 +0000 (20:48 +0100)
Fix a start/scan race in the functional suite: dummy_* mock services
were started and then connected to (by rspamd or the test) before they
were listening. Under parallel pabot the short 2s PID waits timed out
under CPU contention, one-shot helpers (clam/fprot/avast/p0f) left stale
PID files so a same-port restart satisfied Wait Until Created instantly
and raced the new bind, and p0f derived its PID path inconsistently
between helper and suite.

Every dummy_* helper already writes its PID only after server_bind/
server_activate, so PID-existence is a valid "listening" signal. This
routes all helper startup through one barrier:

  * Start Dummy Service (lib/rspamd.robot): drop stale PID, start the
    helper, block until the PID file appears (5s). Single source of
    truth for startup ordering.
  * Wait Until Dummy Listening: active TCP-connect probe layered on top
    for loop servers (http/https/ssl) only; not used for one-shot or
    single-threaded smtp helpers, where a probe would consume the one
    session the test needs.

Rewrite Run Dummy Http/Https/Llm/Http Early/Ssl/Udp/Clam/Fprot/Avast/p0f
and the 168/169 SMTP suites to go through it; move SMTP temp files from
/tmp to the per-worker RSPAMD_TMP_PREFIX; teach dummy_p0f.py to accept an
explicit PID path.

Add util/check_no_bare_dummy_start.py, run as a run-parallel.sh
preflight, which fails if a suite reintroduces a bare
Start Process ... dummy_*.py instead of using the barrier.

test/functional/cases/001_merged/160_antivirus.robot
test/functional/cases/001_merged/310_udp.robot
test/functional/cases/161_p0f.robot
test/functional/cases/168_mx_check_greeting.robot
test/functional/cases/169_mx_check_greeting_quit.robot
test/functional/cases/230_tcp.robot
test/functional/lib/rspamd.robot
test/functional/run-parallel.sh
test/functional/util/check_no_bare_dummy_start.py [new file with mode: 0755]
test/functional/util/dummy_p0f.py

index b0d83a73994387a827f9b0570c4f88438577dcce..f63ed8ebc92611008ef2cf6abbc3594234e4b2dc 100644 (file)
@@ -106,32 +106,23 @@ Double FProt Teardown
   Terminate Process  ${process1}
   Terminate Process  ${process2}
 
-Run Dummy
-  [Arguments]  @{varargs}
-  ${process} =  Start Process  @{varargs}
-  ${pid} =  Get From List  ${varargs}  -1
-  ${pass} =  Run Keyword And Return Status  Wait Until Created  ${pid}
-  IF  ${pass}
-    Return From Keyword
-  END
-  Wait For Process  ${process}
-  ${res} =  Get Process Result  ${process}
-  Log To Console  ${res.stdout}
-  Log To Console  ${res.stderr}
-  Fail  Dummy server failed to start
-  RETURN    ${process}
-
 Run Dummy Clam
   [Arguments]  ${port}  ${found}=  ${pid}=${RSPAMD_TMP_PREFIX}/dummy_clamav-${port}.pid
-  ${process} =  Run Dummy  ${RSPAMD_TESTDIR}/util/dummy_clam.py  ${port}  ${found}  ${pid}
+  ${log} =  Set Variable  ${RSPAMD_TMP_PREFIX}/dummy_clamav-${port}.log
+  ${process} =  Start Dummy Service  dummy_clam.py  ${pid}  ${log}
+  ...  ${RSPAMD_TESTDIR}/util/dummy_clam.py  ${port}  ${found}  ${pid}
   RETURN    ${process}
 
 Run Dummy Fprot
   [Arguments]  ${port}  ${found}=  ${pid}=${RSPAMD_TMP_PREFIX}/dummy_fprot-${port}.pid
-  ${process} =  Run Dummy  ${RSPAMD_TESTDIR}/util/dummy_fprot.py  ${port}  ${found}  ${pid}
+  ${log} =  Set Variable  ${RSPAMD_TMP_PREFIX}/dummy_fprot-${port}.log
+  ${process} =  Start Dummy Service  dummy_fprot.py  ${pid}  ${log}
+  ...  ${RSPAMD_TESTDIR}/util/dummy_fprot.py  ${port}  ${found}  ${pid}
   RETURN    ${process}
 
 Run Dummy Avast
   [Arguments]  ${port}  ${found}=  ${pid}=${RSPAMD_TMP_PREFIX}/dummy_avast-${port}.pid
-  ${process} =  Run Dummy  ${RSPAMD_TESTDIR}/util/dummy_avast.py  ${port}  ${found}  ${pid}
+  ${log} =  Set Variable  ${RSPAMD_TMP_PREFIX}/dummy_avast-${port}.log
+  ${process} =  Start Dummy Service  dummy_avast.py  ${pid}  ${log}
+  ...  ${RSPAMD_TESTDIR}/util/dummy_avast.py  ${port}  ${found}  ${pid}
   RETURN    ${process}
index 4df7f994de61c0525d8094e69e55759757d21d86..b0350f506133e6ca01cf8469dc91e6ae526761ba 100644 (file)
@@ -37,6 +37,7 @@ UDP Teardown
 Run Dummy UDP
   [Arguments]
   ${pid} =  Set Variable  ${RSPAMD_TMP_PREFIX}/dummy_udp-${RSPAMD_PORT_DUMMY_UDP}.pid
-  ${result} =  Start Process  ${RSPAMD_TESTDIR}/util/dummy_udp.py  ${RSPAMD_PORT_DUMMY_UDP}  ${pid}
-  Wait Until Created  ${pid}
+  ${log} =  Set Variable  ${RSPAMD_TMP_PREFIX}/dummy_udp-${RSPAMD_PORT_DUMMY_UDP}.log
+  ${result} =  Start Dummy Service  dummy_udp.py  ${pid}  ${log}
+  ...  ${RSPAMD_TESTDIR}/util/dummy_udp.py  ${RSPAMD_PORT_DUMMY_UDP}  ${pid}
   Set Suite Variable  ${DUMMY_UDP_PROC}  ${result}
index 5d43fd5e83b69413f9e53cc9ff31d317871dc7ac..177e58a364964003ff83938e368a5e9eddc20c22 100644 (file)
@@ -88,15 +88,15 @@ p0f Teardown
   Terminate All Processes    kill=True
 
 Shutdown p0f
-  # dummy_p0f derives its pidfile from the socket basename; mirror that here.
-  ${sockname} =  Evaluate  os.path.basename($RSPAMD_P0F_SOCKET)  modules=os
-  ${pidfile} =  Set Variable  ${RSPAMD_TMP_PREFIX}/dummy_p0f-${sockname}.pid
+  ${pidfile} =  Set Variable  ${RSPAMD_TMP_PREFIX}/dummy_p0f.pid
   ${p0f_pid} =  Get File if exists  ${pidfile}
   Run Keyword if  ${p0f_pid}  Shutdown Process With Children  ${p0f_pid}
 
 Run Dummy p0f
   [Arguments]  ${socket}=${RSPAMD_P0F_SOCKET}  ${os}=linux  ${status}=ok
-  ${sockname} =  Evaluate  os.path.basename($socket)  modules=os
-  ${pidfile} =  Set Variable  ${RSPAMD_TMP_PREFIX}/dummy_p0f-${sockname}.pid
-  ${result} =  Start Process  ${RSPAMD_TESTDIR}/util/dummy_p0f.py  ${socket}  ${os}  ${status}
-  Wait Until Created  ${pidfile}
+  # Pass an explicit pid path so the helper writes exactly where we wait,
+  # and route through Start Dummy Service for the stale-pid-safe barrier.
+  ${pidfile} =  Set Variable  ${RSPAMD_TMP_PREFIX}/dummy_p0f.pid
+  ${log} =  Set Variable  ${RSPAMD_TMP_PREFIX}/dummy_p0f.log
+  Start Dummy Service  dummy_p0f.py  ${pidfile}  ${log}
+  ...  ${RSPAMD_TESTDIR}/util/dummy_p0f.py  ${socket}  ${os}  ${status}  ${pidfile}
index 6a5810598b9bab7711422320b3a901860195288e..7a308d07ff2c82e52ee5a09df91754455c3a8968 100644 (file)
@@ -14,7 +14,7 @@ ${REDIS_SCOPE}        Suite
 ${RSPAMD_SCOPE}       Suite
 ${RSPAMD_URL_TLD}     ${RSPAMD_TESTDIR}/../lua/unit/test_tld.dat
 ${SETTINGS}           {symbols_enabled = [MX_INVALID]}
-${SINGLE_STATUS}      /tmp/dummy_smtp_greeting_single.status
+${SINGLE_STATUS}      ${RSPAMD_TMP_PREFIX}/dummy_smtp_greeting_single.status
 
 *** Test Cases ***
 Silent SMTP listener triggers MX_TIMEOUT_READ
@@ -44,20 +44,13 @@ Non-SMTP line triggers MX_INVALID
 *** Keywords ***
 Start Plain Dummy
   [Arguments]  ${mode}  ${host}
-  Start Process  ${RSPAMD_TESTDIR}/util/dummy_smtp.py
-  ...  --port  11125  --mode  ${mode}  --host  ${host}
-  ...  stderr=/tmp/dummy_smtp_${mode}.log
-  ...  stdout=/tmp/dummy_smtp_${mode}.log
-  Wait Until Created  /tmp/dummy_smtp_${mode}.pid  timeout=2 second
+  Start Dummy Smtp  11125  ${mode}  ${host}
+  ...  ${RSPAMD_TMP_PREFIX}/dummy_smtp_${mode}.pid
 
 Mx Greeting Setup
   Start Plain Dummy  silent  127.0.0.1
-  Start Process  ${RSPAMD_TESTDIR}/util/dummy_smtp.py
-  ...  --port  11125  --mode  greeting_single  --host  127.0.0.2
-  ...  --status-file  ${SINGLE_STATUS}
-  ...  stderr=/tmp/dummy_smtp_greeting_single.log
-  ...  stdout=/tmp/dummy_smtp_greeting_single.log
-  Wait Until Created  /tmp/dummy_smtp_greeting_single.pid  timeout=2 second
+  Start Dummy Smtp  11125  greeting_single  127.0.0.2
+  ...  ${RSPAMD_TMP_PREFIX}/dummy_smtp_greeting_single.pid  --status-file  ${SINGLE_STATUS}
   Start Plain Dummy  error   127.0.0.3
   Start Plain Dummy  messy   127.0.0.4
   Rspamd Redis Setup
index 0d2e96a6f2a963564c9d1b5308894ce268d8130a..6435d5b89a92b5900c2580c216f57d8682162e1b 100644 (file)
@@ -14,8 +14,8 @@ ${REDIS_SCOPE}        Suite
 ${RSPAMD_SCOPE}       Suite
 ${RSPAMD_URL_TLD}     ${RSPAMD_TESTDIR}/../lua/unit/test_tld.dat
 ${SETTINGS}           {symbols_enabled = [MX_INVALID]}
-${PROPER_STATUS}      /tmp/dummy_smtp_greeting_proper.status
-${SLOW_STATUS}        /tmp/dummy_smtp_greeting_slow.status
+${PROPER_STATUS}      ${RSPAMD_TMP_PREFIX}/dummy_smtp_greeting_proper.status
+${SLOW_STATUS}        ${RSPAMD_TMP_PREFIX}/dummy_smtp_greeting_slow.status
 
 *** Test Cases ***
 Multi-line greeting with send_quit=true emits MX_GOOD with QUIT after final line
@@ -32,16 +32,9 @@ Slow second banner line triggers MX_TIMEOUT_READ
 *** Keywords ***
 Start Greeting Dummy
   [Arguments]  ${host}  ${between_wait}  ${status_file}  ${pid_suffix}
-  Start Process  ${RSPAMD_TESTDIR}/util/dummy_smtp.py
-  ...  --port  11125
-  ...  --mode  greeting_multi
-  ...  --host  ${host}
-  ...  --between-wait  ${between_wait}
-  ...  --status-file  ${status_file}
-  ...  --pid-file  /tmp/dummy_smtp_${pid_suffix}.pid
-  ...  stderr=/tmp/dummy_smtp_${pid_suffix}.log
-  ...  stdout=/tmp/dummy_smtp_${pid_suffix}.log
-  Wait Until Created  /tmp/dummy_smtp_${pid_suffix}.pid  timeout=2 second
+  Start Dummy Smtp  11125  greeting_multi  ${host}
+  ...  ${RSPAMD_TMP_PREFIX}/dummy_smtp_${pid_suffix}.pid
+  ...  --between-wait  ${between_wait}  --status-file  ${status_file}
 
 Mx Quit Setup
   Start Greeting Dummy  127.0.0.6  0.2  ${PROPER_STATUS}  greeting_proper
index 2481b95cd517b8735b6c75389d8edce41c517a46..729fe9b616a2de43608f14056a7f5f34dfc59ba7 100644 (file)
@@ -82,9 +82,11 @@ Servers Teardown
 Run Dummy Ssl
   [Arguments]
   ${pid} =  Set Variable  ${RSPAMD_TMP_PREFIX}/dummy_ssl-${RSPAMD_PORT_DUMMY_SSL}.pid
+  ${log} =  Set Variable  ${RSPAMD_TMP_PREFIX}/dummy_ssl-${RSPAMD_PORT_DUMMY_SSL}.log
   Set Suite Variable  ${DUMMY_SSL_PID_FILE}  ${pid}
-  ${result} =  Start Process  ${RSPAMD_TESTDIR}/util/dummy_ssl.py  ${RSPAMD_TESTDIR}/util/server.pem  ${RSPAMD_PORT_DUMMY_SSL}  ${pid}
-  Wait Until Created  ${pid}  timeout=2 second
+  Start Dummy Service  dummy_ssl.py  ${pid}  ${log}
+  ...  ${RSPAMD_TESTDIR}/util/dummy_ssl.py  ${RSPAMD_TESTDIR}/util/server.pem  ${RSPAMD_PORT_DUMMY_SSL}  ${pid}
+  Wait Until Dummy Listening  ${RSPAMD_LOCAL_ADDR}  ${RSPAMD_PORT_DUMMY_SSL}
 
 Teardown Dummy Ssl
   ${ssl_pid} =  Get File  ${DUMMY_SSL_PID_FILE}
index 25c63e8c8190d407f24d9221c7905157736545b6..1f0514502f586ef76313a898f17a09dbfa03c5f3 100644 (file)
@@ -576,57 +576,74 @@ Run Control Command JSON
   Log  ${result.stderr}
   RETURN    ${result}
 
-Run Dummy Http
-  ${pid} =  Set Variable  ${RSPAMD_TMP_PREFIX}/dummy_http-${RSPAMD_PORT_DUMMY_HTTP}.pid
-  ${log} =  Set Variable  ${RSPAMD_TMP_PREFIX}/dummy_http-${RSPAMD_PORT_DUMMY_HTTP}.log
-  ${result} =  Start Process  ${RSPAMD_TESTDIR}/util/dummy_http.py  -pf  ${pid}  -p  ${RSPAMD_PORT_DUMMY_HTTP}
-  ...  stderr=${log}  stdout=${log}
-  ${status}  ${error} =  Run Keyword And Ignore Error  Wait Until Created  ${pid}  timeout=2 second
+Start Dummy Service
+  [Documentation]  Start a dummy_* helper and block until it is ready,
+  ...  then return its process handle. Readiness is the PID file: every
+  ...  dummy_* helper calls dummy_killer.write_pid() only AFTER it has
+  ...  bound and activated its listening socket (see server_bind /
+  ...  server_activate / server.start in util/dummy_*.py), so the moment
+  ...  the PID file appears the kernel is already accepting connections.
+  ...  This keyword is the ONE place that barrier lives -- start every
+  ...  dummy through here (or a Run Dummy * / Start Dummy * wrapper),
+  ...  never via a bare Start Process followed straight by a scan, or you
+  ...  reintroduce the start/scan race that flakes under parallel pabot.
+  [Arguments]  ${name}  ${pidfile}  ${logfile}  @{command}
+  # Drop any stale PID file from a previous instance on this same path.
+  # One-shot helpers (clam/fprot/avast/p0f) exit after a single request
+  # and leave their PID file behind; without this a same-port restart
+  # would satisfy Wait Until Created instantly and race the new bind.
+  Remove File  ${pidfile}
+  ${result} =  Start Process  @{command}  stdout=${logfile}  stderr=${logfile}
+  ${status}  ${error} =  Run Keyword And Ignore Error
+  ...  Wait Until Created  ${pidfile}  timeout=5 second
   IF  '${status}' == 'FAIL'
-    ${logstatus}  ${out} =  Run Keyword And Ignore Error  Get File  ${log}
+    ${logstatus}  ${out} =  Run Keyword And Ignore Error  Get File  ${logfile}
     IF  '${logstatus}' == 'PASS'
-      Log  dummy_http.py failed to start. Log output:\n${out}  level=ERROR
+      Log  ${name} failed to start. Log output:\n${out}  level=ERROR
     ELSE
-      Log  dummy_http.py failed to start. No log file found at ${log}  level=ERROR
+      Log  ${name} failed to start. No log file found at ${logfile}  level=ERROR
     END
-    Fail  dummy_http.py did not create PID file in 2 seconds
-  END
+    Fail  ${name} did not create PID file in 5 seconds
+  END
+  RETURN  ${result}
+
+Wait Until Dummy Listening
+  [Documentation]  Belt-and-suspenders readiness probe on top of the PID
+  ...  barrier: block until a TCP connect to host:port actually succeeds.
+  ...  Self-contained (BuiltIn Evaluate, no library dependency). Use ONLY
+  ...  for helpers that loop and accept many connections (http/https/ssl).
+  ...  Do NOT probe one-shot helpers (clam/fprot/avast/p0f) or the
+  ...  single-threaded smtp helper -- a probe connection would consume or
+  ...  block the very session the scan needs; for those the PID barrier in
+  ...  Start Dummy Service is the correct and sufficient readiness signal.
+  [Arguments]  ${host}  ${port}
+  Wait Until Keyword Succeeds  15x  0.2s
+  ...  Evaluate  __import__('socket').create_connection(("${host}", ${port}), 1).close()
+
+Run Dummy Http
+  ${pid} =  Set Variable  ${RSPAMD_TMP_PREFIX}/dummy_http-${RSPAMD_PORT_DUMMY_HTTP}.pid
+  ${log} =  Set Variable  ${RSPAMD_TMP_PREFIX}/dummy_http-${RSPAMD_PORT_DUMMY_HTTP}.log
+  ${result} =  Start Dummy Service  dummy_http.py  ${pid}  ${log}
+  ...  ${RSPAMD_TESTDIR}/util/dummy_http.py  -pf  ${pid}  -p  ${RSPAMD_PORT_DUMMY_HTTP}
+  Wait Until Dummy Listening  ${RSPAMD_LOCAL_ADDR}  ${RSPAMD_PORT_DUMMY_HTTP}
   Export Scoped Variables  ${RSPAMD_SCOPE}  DUMMY_HTTP_PROC=${result}  DUMMY_HTTP_LOG=${log}
 
 Run Dummy Https
   ${pid} =  Set Variable  ${RSPAMD_TMP_PREFIX}/dummy_https-${RSPAMD_PORT_DUMMY_HTTPS}.pid
   ${log} =  Set Variable  ${RSPAMD_TMP_PREFIX}/dummy_https-${RSPAMD_PORT_DUMMY_HTTPS}.log
-  ${result} =  Start Process  ${RSPAMD_TESTDIR}/util/dummy_http.py
+  ${result} =  Start Dummy Service  dummy_https.py  ${pid}  ${log}
+  ...  ${RSPAMD_TESTDIR}/util/dummy_http.py
   ...  -c  ${RSPAMD_TESTDIR}/util/server.pem  -k  ${RSPAMD_TESTDIR}/util/server.pem
   ...  -pf  ${pid}  -p  ${RSPAMD_PORT_DUMMY_HTTPS}
-  ...  stderr=${log}  stdout=${log}
-  ${status}  ${error} =  Run Keyword And Ignore Error  Wait Until Created  ${pid}  timeout=2 second
-  IF  '${status}' == 'FAIL'
-    ${logstatus}  ${out} =  Run Keyword And Ignore Error  Get File  ${log}
-    IF  '${logstatus}' == 'PASS'
-      Log  dummy_https.py failed to start. Log output:\n${out}  level=ERROR
-    ELSE
-      Log  dummy_https.py failed to start. No log file found at ${log}  level=ERROR
-    END
-    Fail  dummy_https.py did not create PID file in 2 seconds
-  END
+  Wait Until Dummy Listening  ${RSPAMD_LOCAL_ADDR}  ${RSPAMD_PORT_DUMMY_HTTPS}
   Export Scoped Variables  ${RSPAMD_SCOPE}  DUMMY_HTTPS_PROC=${result}
 
 Run Dummy Llm
   ${pid} =  Set Variable  ${RSPAMD_TMP_PREFIX}/dummy_llm-${RSPAMD_PORT_DUMMY_HTTP}.pid
   ${log} =  Set Variable  ${RSPAMD_TMP_PREFIX}/dummy_llm-${RSPAMD_PORT_DUMMY_HTTP}.log
-  ${result} =  Start Process  ${RSPAMD_TESTDIR}/util/dummy_llm.py  ${RSPAMD_PORT_DUMMY_HTTP}  ${pid}
-  ...  stderr=${log}  stdout=${log}
-  ${status}  ${error} =  Run Keyword And Ignore Error  Wait Until Created  ${pid}  timeout=2 second
-  IF  '${status}' == 'FAIL'
-    ${logstatus}  ${out} =  Run Keyword And Ignore Error  Get File  ${log}
-    IF  '${logstatus}' == 'PASS'
-      Log  dummy_llm.py failed to start. Log output:\n${out}  level=ERROR
-    ELSE
-      Log  dummy_llm.py failed to start. No log file found at ${log}  level=ERROR
-    END
-    Fail  dummy_llm.py did not create PID file in 2 seconds
-  END
+  ${result} =  Start Dummy Service  dummy_llm.py  ${pid}  ${log}
+  ...  ${RSPAMD_TESTDIR}/util/dummy_llm.py  ${RSPAMD_PORT_DUMMY_HTTP}  ${pid}
+  Wait Until Dummy Listening  ${RSPAMD_LOCAL_ADDR}  ${RSPAMD_PORT_DUMMY_HTTP}
   Export Scoped Variables  ${RSPAMD_SCOPE}  DUMMY_LLM_PROC=${result}
 
 Dummy Llm Teardown
@@ -644,20 +661,26 @@ Dummy Https Teardown
 Run Dummy Http Early Response
   ${pid} =  Set Variable  ${RSPAMD_TMP_PREFIX}/dummy_http_early-${RSPAMD_PORT_DUMMY_HTTP_EARLY}.pid
   ${log} =  Set Variable  ${RSPAMD_TMP_PREFIX}/dummy_http_early-${RSPAMD_PORT_DUMMY_HTTP_EARLY}.log
-  ${result} =  Start Process  ${RSPAMD_TESTDIR}/util/dummy_http_early_response.py  -pf  ${pid}  -p  ${RSPAMD_PORT_DUMMY_HTTP_EARLY}
-  ...  stderr=${log}  stdout=${log}
-  ${status}  ${error} =  Run Keyword And Ignore Error  Wait Until Created  ${pid}  timeout=2 second
-  IF  '${status}' == 'FAIL'
-    ${logstatus}  ${out} =  Run Keyword And Ignore Error  Get File  ${log}
-    IF  '${logstatus}' == 'PASS'
-      Log  dummy_http_early_response.py failed to start. Log output:\n${out}  level=ERROR
-    ELSE
-      Log  dummy_http_early_response.py failed to start. No log file found at ${log}  level=ERROR
-    END
-    Fail  dummy_http_early_response.py did not create PID file in 2 seconds
-  END
+  ${result} =  Start Dummy Service  dummy_http_early_response.py  ${pid}  ${log}
+  ...  ${RSPAMD_TESTDIR}/util/dummy_http_early_response.py  -pf  ${pid}  -p  ${RSPAMD_PORT_DUMMY_HTTP_EARLY}
+  Wait Until Dummy Listening  ${RSPAMD_LOCAL_ADDR}  ${RSPAMD_PORT_DUMMY_HTTP_EARLY}
   Export Scoped Variables  ${RSPAMD_SCOPE}  DUMMY_HTTP_EARLY_PROC=${result}
 
 Dummy Http Early Teardown
   Terminate Process  ${DUMMY_HTTP_EARLY_PROC}
   Wait For Process  ${DUMMY_HTTP_EARLY_PROC}
+
+Start Dummy Smtp
+  [Documentation]  Start dummy_smtp.py and block until it is listening,
+  ...  then return the process handle for teardown. No connect probe here:
+  ...  the smtp helper runs single-threaded and its modes hold the handler
+  ...  (silent sleeps 30s; greeting modes drive a state machine and write a
+  ...  status file), so a probe connection would borrow the very session
+  ...  the scan needs -- the PID barrier in Start Dummy Service is correct.
+  ...  @{extra} carries optional flags such as --status-file / --between-wait.
+  [Arguments]  ${port}  ${mode}  ${host}  ${pidfile}  @{extra}
+  ${log} =  Set Variable  ${RSPAMD_TMP_PREFIX}/dummy_smtp-${mode}-${host}.log
+  ${result} =  Start Dummy Service  dummy_smtp.py  ${pidfile}  ${log}
+  ...  ${RSPAMD_TESTDIR}/util/dummy_smtp.py  --port  ${port}  --mode  ${mode}
+  ...  --host  ${host}  --pid-file  ${pidfile}  @{extra}
+  RETURN  ${result}
index cdb8d06594e4e704672f41c0e06ff48c9da3d770..d423999d4c3d34f6bb6f381345e0e86f6dc0f620 100755 (executable)
@@ -41,6 +41,11 @@ if ! command -v pabot >/dev/null 2>&1; then
     exit 1
 fi
 
+# Preflight: every dummy_* helper must be started through the centralized
+# Start Dummy Service barrier (lib/rspamd.robot), never a bare Start Process,
+# or the start/scan race that flakes under parallel execution comes back.
+python3 "$SCRIPT_DIR/util/check_no_bare_dummy_start.py" || exit 1
+
 # Suites that still bake dummy_http/llm/udp/http_early port numbers into
 # Lua test scripts or configs. Track in task #5 follow-up; tag and exclude
 # until those are templated.
diff --git a/test/functional/util/check_no_bare_dummy_start.py b/test/functional/util/check_no_bare_dummy_start.py
new file mode 100755 (executable)
index 0000000..2b06865
--- /dev/null
@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+"""Guard against reintroducing the dummy-helper start/scan race.
+
+Every dummy_* helper must be started through the centralized
+``Start Dummy Service`` keyword (or one of the ``Run Dummy *`` /
+``Start Dummy *`` wrappers) defined in ``test/functional/lib/rspamd.robot``.
+Those wrappers block until the helper's PID file appears -- which each
+helper writes only after it has bound and is listening -- so the rspamd
+worker (or the test) never races a not-yet-listening helper.
+
+A bare ``Start Process  .../dummy_<x>.py`` inside a suite bypasses that
+barrier and reintroduces the flakiness this guard exists to prevent.
+This script exits non-zero if it finds one.
+
+Usage:
+    python3 test/functional/util/check_no_bare_dummy_start.py
+"""
+
+import os
+import re
+import sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+CASES = os.path.normpath(os.path.join(HERE, '..', 'cases'))
+REPO = os.path.normpath(os.path.join(HERE, '..', '..', '..'))
+
+# A line that starts a dummy helper directly via Robot's Process library.
+BARE = re.compile(r'Start Process\b.*dummy_[A-Za-z0-9_]*\.py')
+
+
+def main():
+    offenders = []
+    for root, _dirs, files in os.walk(CASES):
+        for name in sorted(files):
+            if not name.endswith('.robot'):
+                continue
+            path = os.path.join(root, name)
+            with open(path, encoding='utf-8') as fh:
+                for lineno, line in enumerate(fh, 1):
+                    if line.lstrip().startswith('#'):
+                        continue
+                    if BARE.search(line):
+                        offenders.append((path, lineno, line.strip()))
+
+    if offenders:
+        sys.stderr.write(
+            "ERROR: bare 'Start Process ... dummy_*.py' found in functional "
+            "suites.\n"
+            "Start dummy helpers through the 'Start Dummy Service' keyword (or "
+            "a\n"
+            "Run Dummy * / Start Dummy * wrapper) in lib/rspamd.robot so they "
+            "block\n"
+            "until the helper is listening. Offending lines:\n\n")
+        for path, lineno, text in offenders:
+            sys.stderr.write(
+                "  {}:{}: {}\n".format(os.path.relpath(path, REPO), lineno, text))
+        return 1
+
+    print("OK: no bare dummy_*.py 'Start Process' in {}".format(
+        os.path.relpath(CASES, REPO)))
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())
index 130474ddd45d883ceaacb102a0fd3c685e684c3d..72cd2c669d326734a25949ef8347551d5989bde1 100755 (executable)
@@ -86,9 +86,14 @@ if __name__ == "__main__":
     server.server_activate()
 
     dummy_killer.setup_killer(server)
-    # Derive PID path from socket basename so multiple workers/instances
-    # never collide on the historical /tmp/dummy_p0f.pid.
-    pid_path = dummy_pidfile.pid_path('p0f', os.path.basename(SOCK))
+    # PID path: explicit 4th arg if the harness passed one (so it can wait
+    # on exactly this path), else derive from the socket basename so
+    # multiple workers/instances never collide on the historical
+    # /tmp/dummy_p0f.pid.
+    if alen > 4:
+        pid_path = sys.argv[4]
+    else:
+        pid_path = dummy_pidfile.pid_path('p0f', os.path.basename(SOCK))
     dummy_killer.write_pid(pid_path)
 
     try: