Direct Answer
Use GLib.Timeout.add() with a callback that calls cancellable.cancel(), then remove the timeout in a finally block after the yield. This is the portable, version-stable pattern across GLib 2.56+.
Minimal Working Pattern
async string fetch_with_timeout(string url, Cancellable cancellable, int timeout_ms = 5000) {
var timeout_id = Timeout.add(timeout_ms, () => {
cancellable.cancel();
return false; // GLib.SOURCE_REMOVE
});
try {
yield http_client.get_async(url, cancellable);
// check cancellation after yield
if (cancellable.is_cancelled()) {
throw new IOError.CANCELLED("Operation timed out");
}
return response_body;
} finally {
Timeout.remove(timeout_id); // critical: all exit paths
}
}
Propagation Decision
Propagate the same Cancellable to all downstream async calls. This is the cooperative contract: the caller creates one cancellable, passes it through the chain, and any timeout or external cancel aborts the entire subtree. Do not create new cancellables per hop unless you intentionally want isolated cancellation scopes.
Cleanup Contract on Mid-Await Cancellation
- The generated state machine unwinds
finally blocks in reverse order — your Timeout.remove() runs. - Any
yield that returns due to cancellation throws IOError.CANCELLED (or sets error on the AsyncResult). Catch it if you need to distinguish timeout from other failures. - Partial state (open file handles, allocated buffers) must be released in
finally blocks. GLib does not auto-rollback.
MainContext Boundary
Timeout callbacks execute in the thread-default MainContext where Timeout.add() was called. If your async work runs in a worker thread with its own MainContext (via ThreadPool or Task.run_in_thread()), the timeout still fires in the original context. This is safe — cancellable.cancel() is thread-safe — but the cancelled signal fires in the worker's context only if you attached it there. For cross-context work, pass the same cancellable; the timeout source stays in the creator's context.
Common Pitfalls
- Returning
true from the timeout callback creates a repeating timer that leaks. Always return false. - Missing
Timeout.remove() on success path causes use-after-free when the callback later touches a disposed cancellable. - No running MainLoop — pure synchronous code or a thread without
MainContext iteration will never fire the timeout.
Verification Checklist
- Run with
G_DEBUG=gc-friendly; leaked sources warn at exit. - Add debug prints in
finally to confirm Timeout.remove() runs exactly once per call. - Test: 100 ms timeout yielding a 500 ms simulated async op; assert
cancellable.is_cancelled() after yield.
Version Note
GLib 2.68+ adds g_cancellable_connect()/disconnect() for cleaner signal handling, but Timeout.add/remove remains the portable timeout mechanism. No Vala version changes the pattern.