pydantic/monty · error · Error

expected event , got

Error message

${what} expected event ${kind}, got ${event.tag}

What it means

After sending a control request, `control()` verifies the reply's event tag matches the expected kind (e.g. `create-result`, `dump-result`, `finish-result`). A mismatch means the worker answered, but with the wrong kind of event — a protocol/schema inconsistency between the JS transport and the compiled wasm component. The message includes both the expected and actual tag for diagnosis.

Solutions

  1. Rebuild and redeploy the wasm component together with the TS layer (`make build-wasm`, then rebuild the JS bundle) so all protocol artifacts come from one source.
  2. Never hand-edit `crates/monty-js/ts/worker/component/` files — they are generated; regenerate them with the build.
  3. Ensure each session processes turns to completion (await each `feedRun`/`dump`/`finish`) so no stale events remain queued before the next control request.
  4. Pin matching versions of `@pydantic/monty` and the compiled component in your deployment.

Example fix

// before
// stale component + new TS bundle -> tag mismatch
const bytes = await session.dump()

// after
make build-wasm   # regenerate component + WIT-derived declarations
make build-js     # rebuild TS layer against them
const bytes = await session.dump()
Defensive patterns

Strategy: try-catch

Try / catch

try {
  result = await session.finish()
} catch (err) {
  if (err instanceof Error && /expected event .*, got /.test(err.message)) {
    // version skew: rebuild component + bindings, then retry on a fresh session
    session = await pool.checkout()
  } else throw err
}

Prevention

When it happens

Trigger: A control request receives an event whose `tag` differs from `kind` — typically caused by version skew between the checked-in WIT declarations, the compiled component, and the TS `ts/worker` layer, or by a worker emitting a terminator event from a previous unfinished turn.

Common situations: Hand-editing or regenerating only part of the component bindings; deploying a cached old wasm module behind an updated JS bundle; reusing a session whose previous turn ended abnormally so the next control call consumes a stale event.

Related errors


AI-assisted analysis of pydantic/monty@adc986b362 (2026-09-13). Data as JSON: /api/errors/84fdee96e318029b. Report an issue: GitHub.

Appendix: source

Thrown at crates/monty-js/ts/worker/transport.ts:314

        turn = aborted ? this.toTurn(aborted) : crashed('worker exited without a turn-ending event')
        // the component answers an abort with an error, never a suspension;
        // servicing one would let a compromised worker call the host past
        // the budget, so it ends the worker instead
        if (turn.kind !== 'error' && turn.kind !== 'crashed') {
          this.dead = true
          turn = { kind: 'protocol', message: `worker answered abort-feed with ${turn.kind}` }
        }
      }
    }
    if (turn.kind === 'crashed') this.dead = true
    return turn
  }

  /** Sends a control request and verifies its expected event kind. */
  private async control(request: ComponentRequest, kind: ComponentEvent['tag'], what: string): Promise<ComponentEvent> {
    const event = await this.run(request, undefined)
    if (!event) throw new Error(`${what} produced no turn-ending event (worker crashed)`)
    if (event.tag !== kind) throw new Error(`${what} expected event ${kind}, got ${event.tag}`)
    return event
  }

  /** Runs one turn, forwarding buffered prints and retaining its terminator. */
  private async run(request: ComponentRequest, onPrint: OnPrint | undefined): Promise<ComponentEvent | null> {
    let events: ComponentEvent[]
    try {
      const result = await this.dispatcher(request)
      if (result.status === 'shutdown') this.dead = true
      // the component reports the limit in force (the configured one, else
      // the 1000 default; a dump's on load), so it is adopted from the reply
      if (request.tag === 'configure' || request.tag === 'load') {
        this.suspensionLimit = result.maxSuspensions
        this.suspensionsSeen = 0n
      }
      events = result.events
    } catch {
      return null

View on GitHub (pinned to adc986b362)