Skip to content

Document and replicate the closure .sync fast-path convention beyond sound #832

Description

@martin-henz

Summary

Once a closure crosses the module boundary into student code (e.g. sine_wave's returned wave, or a planned get_pixel_value), the evaluator can only call it back synchronously — required whenever student code is itself already running inside a synchronous host callback (e.g. a wave being sampled by play) — if that closure carries an optional .sync twin: a plain, Promise/generator-free function computing the same result. Without one:

  • called from inside such a synchronous callback, it fails outright with "... needs a frontend round-trip and cannot be called from a synchronous module callback" (see py2js: closure_call_sync fast path for module closures called from Python py-slang#353)
  • even in ordinary (non-nested) use, every call pays the mandatory AsyncGenerator cost instead of a plain function call — source-academy/py-slang's own benchmark puts this at ~390-420ns/call vs. ~15-20ns/call, the difference between a usable per-frame budget and not, for something sampled thousands of times per frame/second

Reference implementation: sound already does this

sound's Conductor migration (#796) already implements the pattern correctly, and is the model other bundles should copy:

// types.ts
export type SyncWave = (t: number) => number;
export type Wave = {
  (t: number): AsyncGenerator<void, number, undefined>;
  readonly sync?: SyncWave;
};

// functions.ts — any wave built purely from module-native math gets a .sync twin for free
function syncWave(fn: SyncWave): Wave {
  const wave = (async function* (t: number) {
    return fn(t);
  }) as Wave;
  return Object.assign(wave, { sync: fn });
}

export function sine_wave(freq: number): Wave {
  return syncWave(t => Math.sin(2 * Math.PI * t * freq));
}

// index.ts — threading Wave.sync through to the actual Conductor closure
function waveToConductorClosure(evaluator: IDataHandler, wave: Wave): Promise<TypedValue<DataType.CLOSURE>> {
  async function* conductorWave(t: TypedValue<DataType.NUMBER>) {
    return { type: DataType.NUMBER as const, value: yield* wave(t.value) };
  }
  if (wave.sync) {
    const sync = wave.sync;
    Object.assign(conductorWave, {
      sync: (t: TypedValue<DataType.NUMBER>): TypedValue<DataType.NUMBER> => ({
        type: DataType.NUMBER as const,
        value: sync(t.value),
      }),
    });
  }
  return evaluator.closure_make({ returnType: DataType.NUMBER, args: [DataType.NUMBER] }, conductorWave);
}

@moduleMethod([DataType.NUMBER], DataType.CLOSURE)
async* sine_wave(freq: TypedValue<DataType.NUMBER>): AsyncGenerator<void, TypedValue<DataType.CLOSURE>, undefined> {
  return waveToConductorClosure(this.evaluator, sine_wave_func(freq.value));
}

It falls back to the plain AsyncGenerator path the instant a student-authored closure enters the composition (closureToWave never sets .sync), so correctness for student-authored waves is unaffected — only module-native closures get the fast path.

What's missing

  • stereo_sound — essentially the same wave-sampling architecture, same per-sample rate as sound — has no .sync at all yet. Its wave closures always pay the full AsyncGenerator cost, and (once py2js: closure_call_sync fast path for module closures called from Python py-slang#353 merges) still can't be called synchronously from inside a student's composed wave.
  • No written guidance exists anywhere in docs/ telling module implementers when/why to do this. It's only discoverable by reading sound's source. Every other already-migrated bundle (midi, rune, repeat, scrabble, binary_tree, plotly) has zero .sync usage — some may have no need for it, but nothing currently prompts an implementer to even consider it.
  • As more bundles migrate to Conductor, each is at risk of independently rediscovering (or simply missing) this. pix_n_flix's planned get_pixel_value/set_pixel_value (sampled up to width×height×8 times/frame — see Preserve a moduleMethod's .sync twin through BaseModulePlugin.initialise() conductor#54) is the next obvious candidate.

Ask

  1. Port sound's .sync convention to stereo_sound.
  2. Write this up as a documented convention for module implementers migrating to Conductor (e.g. under docs/src/modules/5-advanced/), stated as a general pattern rather than a sound-specific trick:

    Attach a .sync twin to any closure you hand back to student code whose underlying computation is provably synchronous — not selectively, based on where you expect it to be used. Once a closure is in the student's hands you can't control where it goes: it may be passed into a different module's own synchronous sampling loop, not just the one it came from. Reserve the AsyncGenerator-only path for closures that genuinely need a real host round-trip (actual I/O, a real async browser API) — those can't have a meaningful .sync twin, and that's the correct, permanent behavior for them, not a gap to close.

  3. Sweep other already-migrated bundles for closure-returning methods that could benefit from the same treatment.

Related

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions