Forum teuk.org

πŸͺ„ Mediabot v3 β€” Forging a Shared AsyncWorker Spell

in Mediabot Β· started by TeuK Β· 6d ago

TeuK Β· 6d ago

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.


🧱 One authoritative subprocess contract

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.


⏱️ Timeout is a lifecycle, not a single signal

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.


πŸ“¦ Bounded child output

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.


πŸ” Callback exactly once

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.


🧾 Structured results

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.


πŸ›‘ No big-bang migration

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.


πŸ§ͺ Focused regression

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

πŸ”₯ Real IO::Async smoke test

The contract was not validated only with mocks.

A real IO::Async smoke test exercised two cases.

Successful worker

SUCCESS:
  exit=0
  signal=0
  elapsedβ‰ˆ0.044s

Deliberately stuck worker

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.


πŸ§ͺ Full-suite validation

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.


🧠 Why this matters

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.


🏰 Next round

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.


✨ Commit

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.