Ship

Book · Timeouts

Timeouts

Draft

Bound a function's running time with a Timeout driver stage.

On this page · 2 sections

A deadline is a stage

Timeout.after(duration) is a Drive value, so it attaches to a function with >>. Each call races the function's body against a Clock deadline. A body that finishes first returns its value; otherwise the call raises :timeout. The stage adds :timeout to the row and Clock to the effects.

type TimeoutProbe = {
  mut events: Vector(String);
}

let recordrecord: fn(probe: Book.Concurrency.Timeout.TimeoutProbe @ global, event: String @ global) -> Unit = fn(probeprobe: Book.Concurrency.Timeout.TimeoutProbe, eventevent: String) {
  probe.events := Vector.pushpush: pure fn!('a)(items: Vector(String), item: String) -> Vector(String)(probeprobe: Book.Concurrency.Timeout.TimeoutProbe.eventsevents: Vector(String), eventevent: String);
};

let epochepoch: Instant = must Instant.parse_rfc3339parse_rfc3339: pure fn(string: String) -> Instant, exn [ :parse(String) ]("2026-01-01T00:00:00Z""2026-01-01T00:00:00Z": String);

let slow_answerslow_answer: fn(delay: Duration @ global) -> Int, exn [ :timeout ] = fn(delaydelay: Duration: Duration) {
  Clock.sleepsleep: fn!(duration: Duration) -> Unit, eff Clock(delaydelay: Duration);
  4242: Int
} >> Timeout.afterafter: pure fn!(duration: Duration) -> Timeout.After(Duration.secondsseconds: pure fn!(value: Int) -> Duration(22: Int));

Body Wins

test✓
  provide { Clock = Clock.virtualvirtual: pure fn!(start: Instant) -> Service.Provider(Clock)(epochepoch: Instant); } {
    let startstart: Clock.MonotonicInstant = Clock.markmark: fn!() -> Clock.MonotonicInstant();
    expect ✓try slow_answerslow_answer: fn(delay: Duration @ global) -> Int, exn [ :timeout ](Duration.secondsseconds: pure fn!(value: Int) -> Duration(11: Int)) {
      | :timeout -> 00: Int
    } == 4242: Int;
    expect ✓Clock.elapsed_sinceelapsed_since: fn!(mark: Clock.MonotonicInstant) -> Duration(startstart: Clock.MonotonicInstant) == Duration.secondsseconds: pure fn!(value: Int) -> Duration(11: Int);
  };

Deadline Wins

test✓
  provide { Clock = Clock.virtualvirtual: pure fn!(start: Instant) -> Service.Provider(Clock)(epochepoch: Instant); } {
    let startstart: Clock.MonotonicInstant = Clock.markmark: fn!() -> Clock.MonotonicInstant();
    expect ✓try slow_answerslow_answer: fn(delay: Duration @ global) -> Int, exn [ :timeout ](Duration.secondsseconds: pure fn!(value: Int) -> Duration(99: Int)) {
      | :timeout -> 00: Int
    } == 00: Int;
    expect ✓Clock.elapsed_sinceelapsed_since: fn!(mark: Clock.MonotonicInstant) -> Duration(startstart: Clock.MonotonicInstant) == Duration.secondsseconds: pure fn!(value: Int) -> Duration(22: Int);
  };

The loser is interrupted

The body runs on its own fiber. When the deadline wins, that fiber is interrupted: its cleanup runs and it never resumes. Because the body outlives the call's own frame, everything it captures must be @ global.

let guardedguarded: fn(probe: Book.Concurrency.Timeout.TimeoutProbe @ global) -> Unit, exn [ :timeout ] = fn(probeprobe: Book.Concurrency.Timeout.TimeoutProbe: TimeoutProbe @ global) {
  defer recordrecord: fn(probe: Book.Concurrency.Timeout.TimeoutProbe @ global, event: String @ global) -> Unit(probeprobe: Book.Concurrency.Timeout.TimeoutProbe, "cleaned""cleaned": String);
  Clock.sleepsleep: fn!(duration: Duration) -> Unit, eff Clock(Duration.hourshours: pure fn!(value: Int) -> Duration(11: Int));
  recordrecord: fn(probe: Book.Concurrency.Timeout.TimeoutProbe @ global, event: String @ global) -> Unit(probeprobe: Book.Concurrency.Timeout.TimeoutProbe, "finished""finished": String);
} >> Timeout.afterafter: pure fn!(duration: Duration) -> Timeout.After(Duration.secondsseconds: pure fn!(value: Int) -> Duration(11: Int));

Loser Runs Cleanup

test✓
  let probeprobe: Book.Concurrency.Timeout.TimeoutProbe: TimeoutProbe @ global = TimeoutProbe { events = []; };
  provide { Clock = Clock.virtualvirtual: pure fn!(start: Instant) -> Service.Provider(Clock)(epochepoch: Instant); } {
    expect ✓match guardedguarded: fn(probe: Book.Concurrency.Timeout.TimeoutProbe @ global) -> Unit, exn [ :timeout ](probeprobe: Book.Concurrency.Timeout.TimeoutProbe) {
      | _ -> "finished""finished": String
      | error :timeout -> "timeout""timeout": String
    } == "timeout""timeout": String;
  };
  expect ✓probeprobe: Book.Concurrency.Timeout.TimeoutProbe.eventsevents: Vector(String) == ["cleaned""cleaned": String];

Errors the body raises propagate as they would without the stage.

let failingfailing: fn() -> Int, exn [ 'a | :busy | :timeout ], eff 'b = fn() {
  error :busy;
  00: Int
} >> Timeout.afterafter: pure fn!(duration: Duration) -> Timeout.After(Duration.secondsseconds: pure fn!(value: Int) -> Duration(11: Int));

Body Errors Propagate

test✓
  provide { Clock = Clock.virtualvirtual: pure fn!(start: Instant) -> Service.Provider(Clock)(epochepoch: Instant); } {
    expect ✓match failingfailing: fn() -> Int, exn [ :busy | :timeout ]() {
      | valuevalue: Int -> valuevalue: Int
      | error :busy -> 11: Int
      | error :timeout -> 22: Int
    } == 11: Int;
  };