Forum teuk.org

🧠⏳πŸͺ„ Mediabot v3 and the Stubborn Trivia Goblet β€” MB394 to MB396

in Mediabot Β· started by TeuK Β· 1mo ago

TeuK Β· 1mo ago

The previous chapter brought Mediabot closer to a future 3.3 release: private data stayed out of the logs, version reporting became deterministic, reconnects survived a second outage, PID ownership became atomic, core commands stopped flooding IRC, and the CPAN-only installer finally included the real MariaDB driver.

Then one very small command refused to cooperate:

m trivia

Instead of a question, the channel received only:

Trivia: could not fetch question.

That message was technically true and operationally useless.

The failure could have been anywhere between Open Trivia DB, DNS, TLS, the HTTP client, JSON parsing, the forked worker, the pipe, process collection or the final IRC callback. Mediabot did not know which door had jammed β€” and neither did its administrator.

MB394 through MB396 turned that opaque failure into a bounded, observable and working asynchronous pipeline.

The project remains on the 3.2dev line.

The stable 3.3 release has not been declared.

As always:

No database schema was changed.


πŸ§ͺ MB394 β€” Open Trivia DB rate limits stop looking like random failure

Open Trivia DB may return API response code 5 when several requests share the same public IP inside its five-second window. An HTTP 429 can represent the same condition.

The old code collapsed that situation into the generic failure shown above.

MB394 added one bounded retry:

response_code 5 or HTTP 429
wait slightly longer than five seconds
retry once
never sleep in the IRC event loop

The wait happens inside the child worker, not inside the main bot process.

The final result now distinguishes:

rate limiting
HTTP failure
invalid API response
JSON parsing failure

The channel also receives a useful rate-limit message instead of being told only that something went wrong.

This solved one real API behaviour, but the live command still failed.


🧡 MB395 β€” One process must have one collector

The Trivia worker ran in a child process while the parent used IO::Async.

Its completion path still polled the PID with:

waitpid($child_pid, POSIX::WNOHANG())

That gave two pieces of code an interest in collecting the same child:

IO::Async process handling
Trivia's manual waitpid polling

A valid worker result could therefore be lost if the loop collected the PID first and the Trivia poll later saw no child to reap.

MB395 replaced the polling with:

$loop->watch_process($child_pid, sub { ... })

It also added correlated diagnostics carrying only bounded metadata:

channel
requesting nick
token
worker PID
stage
HTTP status
API response code
payload byte count
elapsed time
exit code
signal

Question text, answer text and raw remote JSON are never written to the logs.

The first live test after MB395 finally exposed the next problem:

trivia worker start
24 seconds without completion
TERM sent
KILL sent

At the same time:

m check

still answered normally.

That observation mattered. The main IRC loop was healthy. Only the Trivia child and its completion path were stuck.


πŸ” MB396 β€” The worker reports where it is before it disappears

MB395 still used the special Perl form:

open(my $pipe, '-|')

while also asking IO::Async to watch the resulting child PID.

MB396 removed that ambiguous ownership model.

The worker now uses three explicit operations:

pipe(...)
fork()
$loop->watch_process(...)

Responsibilities are clear:

ordinary pipe       β†’ transports worker records
IO::Async stream    β†’ reads those records
watch_process       β†’ owns child collection

πŸ›°οΈ A small progress protocol

The child no longer stays silent until the final JSON result.

It emits bounded progress records such as:

http_client_start
http_client_ready
http_get_start
http_get_done
http_get_timeout
rate_limit_wait_start
rate_limit_wait_done
api_parse_start
api_parse_ok
api_parse_failed

The parent records the last stage together with safe metadata:

attempt
elapsed milliseconds
HTTP status
content bytes
response code
parse class
retry delay

This makes a future failure diagnosable without leaking Trivia content.

⏱️ A hard wall around the HTTP request

The HTTP client timeout alone did not provide a reliable wall-clock guarantee for every possible stall.

MB396 adds a hard deadline around each complete request with Time::HiRes::alarm().

A blocked request becomes an explicit result:

error=http_timeout
stage=http_get
last_stage=http_get_timeout

The public command receives:

Trivia: the question service request timed out. Details were logged.

🧯 Completion is guaranteed after timeout

The previous timeout path still waited for child_done before invoking the command callback.

The live logs proved that this notification could fail to arrive even after TERM and KILL.

The new sequence is:

outer timeout expires
TERM is sent and delivery is logged
KILL follows after 0.5 second if required
forced finalisation follows after another 1.5 seconds if required

The pending Trivia request is always cleared.

The channel always receives a final result.

A failed child can no longer leave m trivia permanently occupied or vanish after two signal lines.

🧬 Forked signal handlers return to normal

The child resets inherited handlers for:

TERM
INT
HUP

A timeout signal can therefore terminate the child normally instead of accidentally entering the parent bot’s shutdown handler inside the forked process.


πŸ›‘οΈ The installer caught its own mistake

The first MB396 installer generated code with the pipe handles declared inside the unless condition:

unless (pipe(my $pipe, my $child_write)) {

Under use strict, those lexicals were not visible to the later fork branches.

The mandatory syntax check rejected the patch before any restart:

Global symbol "$pipe" requires explicit package name
Global symbol "$child_write" requires explicit package name

The automatic rollback restored the previous working tree.

The corrected revision declares the handles in the enclosing scope:

my ($pipe, $child_write);
unless (pipe($pipe, $child_write)) {

This was not a runtime regression. It was a failed installation safely stopped by the guardrails that were designed for exactly this situation.


πŸ§ͺ Validation ledger

The final focused Trivia selection reports:

154/154

It covers:

existing Trivia command guards
asynchronous worker behaviour
word-boundary answer matching
Open Trivia DB rate-limit retry
IO::Async process ownership and diagnostics
progress protocol
hard HTTP deadline
forced timeout completion

The corrected installer also completed its real syntax check on the development server before the bot was restarted.

The decisive live result is simple:

m trivia works again

The bot remained responsive throughout the investigation, and the final implementation now provides enough stage information to diagnose a future network or API failure precisely.

The validation groups from MB394, MB395 and MB396 overlap and must not be added into one artificial total.


πŸ“ Files involved

Mediabot/UserCommands.pm
t/cases/541_mb319_trivia_async_fetch.t
t/cases/612_mb394_trivia_rate_limit_retry.t
t/cases/613_mb395_trivia_process_watch_diagnostics.t
t/cases/614_mb396_trivia_stage_protocol_deadline.t

The older MB319 test was realigned with the explicit pipe() + fork() contract.


🧱 Database and release impact

None.

0 new tables
0 altered columns
0 migrations
0 configuration changes
0 changes to commit.sh
0 declaration of version 3.3

The current public line remains:

3.2dev-*

πŸ† What changed

Mediabot Trivia now:

  • recognises Open Trivia DB’s IP rate limit and retries once without blocking IRC;
  • gives IO::Async sole ownership of child-process collection;
  • separates pipe transport from PID lifecycle management;
  • reports its current HTTP and parsing stage in real time;
  • enforces a hard deadline around each HTTP request;
  • records signal delivery and the last observed stage;
  • guarantees a final callback even when process notification is delayed;
  • clears pending state after every success, error or forced timeout;
  • keeps question and answer content out of diagnostic logs;
  • and, most importantly, serves Trivia questions again.

The Goblet was not empty.

It was waiting behind a rate limit, a child-process ownership dispute and a timeout path that never finished its spell.

Now the worker has one collector, every stage leaves a safe footprint, every timeout has an exit, and the question finally reaches the channel.

You must be logged in to reply.