Commit: bf9c362
Version: 3.4dev-20260817_080225
Mediabot now has a common subprocess contract.
For years, several features had to solve variations of the same problem:
fork
pipe
watch_process
EOF
timeout
TERM
KILL
callback
JSON child β parent
The individual implementations worked, but every new asynchronous feature risked rediscovering the same lifecycle bugs.
MB651 introduces the shared abstraction:
Mediabot::AsyncWorker
The important part is what did not happen in this round:
no existing consumer was migrated yet.
The abstraction was built, tested, exercised against real IO::Async, and committed on its own before touching Version, Trivia, Achievements or any other worker.
Mediabot::AsyncWorker now provides one common implementation for:
pipe creation
fork lifecycle
IO::Async::watch_process
bounded child output
JSON child β parent
exit-code reporting
signal reporting
timeout handling
TERM escalation
KILL escalation
liveness backstop
explicit cancellation
callback exactly once
The child side terminates with POSIX::_exit, avoiding accidental execution of parent cleanup logic after a fork.
A timeout is not treated as:
send TERM and hope
The shared worker follows an explicit escalation path:
deadline reached
β
TERM
β
grace period
β
KILL if still alive
β
final structured result
The result records whether termination and escalation were required.
That gives consumers enough information to distinguish:
normal success
child failure
signal termination
timeout
forced kill
cancel
protocol failure
without each feature inventing its own interpretation.
Subprocess output is deliberately bounded.
A child that misbehaves must not be able to grow an unbounded parent-side buffer.
The worker therefore treats output size as part of the contract rather than an implementation detail.
That is especially important for workers that return structured JSON.
One of the easiest asynchronous bugs to create is a callback firing twice:
EOF callback
+
process-exit callback
or:
timeout path
+
late child exit
The shared implementation owns finalisation and enforces callback-once semantics.
Consumers should receive one terminal result for one worker.
No duplicate IRC reply.
No second cleanup.
No race between timeout and process-watch completion.
The parent receives a structured result containing the useful lifecycle facts instead of having to reconstruct them from scattered state.
The contract includes information such as:
success/failure
decoded payload
raw output metadata
exit status
signal
timeout state
TERM sent
KILL sent
elapsed time
error reason
The exact consumer policy remains outside the worker.
For example, a Version checker may choose a quiet fallback while another feature may expose an operator warning.
The shared module owns mechanics, not product policy.
This was a deliberate design constraint.
MB651 does not migrate:
Version checker
Trivia
Achievements
CommandAsync
YouTube
That separation matters.
If the new abstraction and the first consumer migration landed together, a failure could come from either:
the AsyncWorker contract
or:
the consumer adaptation
By committing the abstraction first, the next migration has a stable baseline.
The new contract is protected by:
t/cases/833_mb651_asyncworker_contract.t
and was tested together with existing asynchronous lifecycle coverage:
751_mb559_async_achievement_worker.t
822_mb642_version_worker_process_watch.t
823_mb643_version_worker_pipe_fork_liveness.t
833_mb651_asyncworker_contract.t
86_module_structure_sanity.t
Result:
PASSED : 264/264
IO::Async smoke testThe contract was not validated only with mocks.
A real IO::Async smoke test exercised two cases.
SUCCESS:
exit=0
signal=0
elapsedβ0.044s
The second worker was intentionally made to exceed its timeout.
Observed result:
TIMEOUT:
error=worker_timeout
signal=9
term=1
kill=1
So the real lifecycle proved:
timeout
β TERM
β process remains alive
β KILL
β one final result
exactly as designed.
After the focused and real-process tests, the entire Mediabot suite passed:
PASSED : 13285/13285
RC=0
Runtime remained around the usual fifteen minutes:
903s
That is important because MB651 adds infrastructure while leaving all existing consumers untouched.
Several historical async bugs were not really feature bugs.
They were variations of lifecycle bugs:
who closes the pipe?
what happens if the child never exits?
what if EOF arrives before watch_process?
what if the timeout races with normal completion?
what if TERM is ignored?
what if JSON is truncated?
what if the callback fires twice?
Those questions should not be answered independently by every Mediabot feature.
MB651 gives them one home.
The next step is intentionally small:
migrate exactly one existing consumer to
Mediabot::AsyncWorker.
The first candidate is the Version checker.
That worker already has dedicated lifecycle tests, making it a good proving ground before touching the much heavier Trivia worker.
If the Version migration behaves correctly in isolation and on a real running instance, then the abstraction will have earned the right to move further.
bf9c362
πͺ Forge a Shared AsyncWorker Spell for Safe Subprocesses
Version:
3.4dev-20260817_080225
Committed scope:
CHANGELOG.md
VERSION
Mediabot/AsyncWorker.pm
t/cases/833_mb651_asyncworker_contract.t
The shared spell is now in the Grimoire.
No consumer has cast it yet.
You must be logged in to reply.