Skip to content

Add run-scoped shared resources#4

Merged
russwyte merged 1 commit into
mainfrom
run-resource
Jun 25, 2026
Merged

Add run-scoped shared resources#4
russwyte merged 1 commit into
mainfrom
run-resource

Conversation

@russwyte

Copy link
Copy Markdown
Collaborator

Why

Executable doc blocks re-run their side effects on every marklitGenerate — the block cache stores compiled classes, not output, so execution always replays. When docs talk to a stateful external system (a DB, a server, a temp dir), a second generate in the same warm sbt session sees the first run's leftovers and fails.

This surfaced in the saferis docs: run 1 (21 files) all SUCCEED, run 2 has 4 files FAIL — those blocks do non-idempotent DB work (createTable without ifNotExists, duplicate-key inserts) against a Postgres testcontainer that survives the whole session via a JVM system property. The old subprocess-per-generate model hid this; the warm in-process session exposes it. Until now the only fix was a hand-rolled JVM-static + system-property bridge.

What

A first-class run-scoped resource lifecycle, build-provided and ZIO-bracketed:

  • JDK seam, no marklit dependency. The resource class implements java.util.function.Supplier[AutoCloseable]. get() is setup (returns the teardown as the AutoCloseable). Using a JDK type lets the instance cross marklit's classloader boundary, so the user's class needs no dependency on any marklit artifact.
  • Acquire once / close once. marklit acquires the resource before any file and closes it after the last, inside a per-run ZIO.scoped + acquireRelease in MarklitRun.runWith — teardown fires even on failure. Setup/teardown failures degrade to notices, never failing an otherwise-good run. The warm CompilerFactory is untouched; only the resource is per-run.
  • Shared across all blocks. A per-run user classloader holds the instance and parents every block's execution loader, so all blocks — including zio-app blocks — share the same instance (and the same ZIO). With no resource configured, behavior is byte-for-byte unchanged.

Surface

  • MarklitRunConfig.runResourceClass; acquireRunResource bracket; CompilerFactory.userClassLoader (scoped per-run loader); ScalaCompiler.shareUserClasses.
  • Plumbed through sbt (marklitRunResourceClass), Mill (def marklitRunResourceClass), CLI (--run-resource).

Note / tradeoff

When a run resource is configured, zio-app blocks share the per-run ZIO instead of getting fresh per-block init — sharing is the point. With no resource configured, the per-block-fresh path is unchanged.

Tests

  • RunResourceSpec (6 cases): no-op baseline unchanged; exactly-once acquire→close; plain-object sharing; zio-app sharing (no cross-loader LinkageError); setup-failure-as-notice; teardown-failure-as-notice.
  • New marklit/run-resource scripted test: two generates in one sbt session — the second hits cache 100% yet still re-executes, and the lifecycle log is exactly acquire,close,acquire,close.
  • All suites green (core 261, compiler 73, cli 16, shim 4); existing basic scripted test unchanged; full build incl. the Mill plugin compiles.

Docs

README "Run-scoped resources" section + CLI flag table entry.

A saferis-side migration (add a DocResource, set marklitRunResourceClass, drop the system-property hack) is a separate change in that repo.

🤖 Generated with Claude Code

Executable doc blocks re-run their side effects on every marklitGenerate
(the block cache stores compiled classes, not output, so execution always
replays). When docs talk to stateful external systems, a second generate in
the same warm session sees the first run's leftovers. Previously the only
workaround was a hand-rolled JVM-static + system-property bridge.

Add a build-provided run resource: a class on the docs' classpath
implementing java.util.function.Supplier[AutoCloseable]. marklit acquires it
once before any file and closes it once after the last, inside a per-run
ZIO.scoped boundary so teardown fires even on failure. The JDK seam means the
resource class needs no dependency on marklit.

While a resource is configured, a per-run user classloader holds the instance
and parents every block's execution loader, so all blocks — including zio-app
blocks — share the same instance (and the same ZIO). With no resource
configured, behavior is byte-for-byte unchanged.

- MarklitRunConfig.runResourceClass; acquireRunResource bracket in
  MarklitRun.runWith; CompilerFactory.userClassLoader builds the scoped per-run
  loader; ScalaCompiler.shareUserClasses parents block loaders off it.
- Plumbed through the sbt plugin (marklitRunResourceClass), Mill
  (def marklitRunResourceClass), and CLI (--run-resource).
- RunResourceSpec covers no-op baseline, exactly-once lifecycle, plain- and
  zio-app-block sharing, and setup/teardown failure-as-notice. New
  marklit/run-resource scripted test proves it end-to-end through the plugin.
- README: Run-scoped resources section + CLI flag.
@russwyte russwyte merged commit 83e8fb3 into main Jun 25, 2026
1 check passed
@russwyte russwyte deleted the run-resource branch June 25, 2026 13:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant