Skip to content

docs: say background pickup needs run(signal) - #27

Merged
cardmagic merged 2 commits into
mainfrom
docs/require-run-for-pickup
Aug 24, 2026
Merged

docs: say background pickup needs run(signal)#27
cardmagic merged 2 commits into
mainfrom
docs/require-run-for-pickup

Conversation

@cardmagic

Copy link
Copy Markdown
Owner

Closes #22.

Why

install() migrates the schema and starts nothing. The README's
programming-model example completes anyway, because a direct call runs on the
caller's own path, so the page never had a reason to mention roles. A reader
who builds a two-process harness from that example gets a process that
registers actors, installs, and then waits forever, because nothing polls until
runtime.run(signal) starts the roles. An external prober did exactly that
against 0.14.0, watched ready messages sit for 30 seconds, and concluded
messages were stranded.

Nothing was wrong with the runtime. The page was missing one sentence.

What changed

README, right after the model example:

install() prepares the database and starts nothing. The example above
finishes because the caller's own path executes each call. A process serves
background work only after runtime.run(signal) starts its roles, so a
process that installs and then waits never claims a ready message. Nothing is
lost while no process runs. The message stays ready until one does.

with the shape a real process uses:

const controller = new AbortController()
process.on("SIGTERM", () => controller.abort())
await runtime.run(controller.signal)

docs/operations.md, first paragraph: the same claim, plus the boundary that
makes it make sense. A direct call or an explicit sync needs no running role.

The same example addressed its actor as runtime.ref(Cart, "cart-123")
while every other reference example in the README uses the static form. It now
reads Cart.ref("cart-123"). createRuntime sets the default runtime, so the
example still runs as written.

Keeping the sentence honest

test/background-pickup.test.ts pins the documented behavior:

const message = await runtime.ref(Mailbox, "inbox").send.receive()
await new Promise((resolve) => setTimeout(resolve, 200))

expect(await message.status()).toBe("ready")     // install() started nothing

const running = runtime.run(controller.signal)
await message.wait({ timeoutMilliseconds: 2_000 })

expect(await message.status()).toBe("completed") // run(signal) picked it up

A test that only ever sees ready proves nothing, so I checked that the first
assertion detects pickup. Replacing the wait with runtime.worker().runOnce()
turns it red:

AssertionError: expected 'completed' to be 'ready' // Object.is equality
Expected: "ready"
Received: "completed"

Ruby counterpart

cardmagic/solid-objects-ruby#52 covers the same gap for the gem. Ruby was in
better shape already: the README has a Worker requirements table that says a
missing worker leaves the message pending. What it lacked was the pointer at
the point of use, in the async section, and the equivalent statement in the
operations guide. That PR adds both, plus the matching test.

Validation

  • pnpm run check: clean.
  • pnpm test: 343 pass, 13 skipped.
  • Mutation check on the new test, quoted above.

install() migrates the schema and starts nothing. The README's
programming-model example completes without run() because the caller's
own path executes the call, so the page never had to mention roles. A
reader who builds a two-process harness from it hits a process that
registers actors, installs, and then waits forever, because nothing
polls. An external prober did exactly that against 0.14.0 and read the
unclaimed messages as stranded.

The README now says it after the model example, with the AbortController
shape a real process uses, and docs/operations.md says it in the first
paragraph. Both add the part that keeps the reader calm: nothing is lost
while no process runs, the message stays ready until one does.

test/background-pickup.test.ts keeps the sentence honest. A sent message
reads ready 200ms after install, then completed once run(signal) starts
the roles. Driving a worker before the first assertion turns it red, so
the assertion detects pickup rather than asserting a constant.

The same example addressed its actor as runtime.ref(Cart, "cart-123")
while every other reference example in the README uses the static form.
It now reads Cart.ref("cart-123").

Closes #22.
@greptile-apps

greptile-apps Bot commented Aug 24, 2026

Copy link
Copy Markdown

Greptile Summary

The PR clarifies that install() only prepares storage and that background processing requires runtime.run(signal).

  • Updates the programming-model example to use configure() with the static Cart.ref() form.
  • Documents runtime-role startup behavior in the README and operations guide.
  • Adds regression coverage showing a sent message remains ready until runtime roles start.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
README.md Correctly configures the default runtime, demonstrates controlled role startup, and explains why direct calls work without active background roles.
docs/operations.md Clarifies the operational distinction between schema installation, direct execution, and background role processing.
test/background-pickup.test.ts Passively polls message status, avoiding caller-side processing and isolating pickup by the actor worker started through run().
CHANGELOG.md Accurately records the documentation correction, regression test, and switch to default-runtime configuration.

Reviews (2): Last reviewed commit: "fix: make the model example configure th..." | Re-trigger Greptile

Comment thread README.md
Comment thread test/background-pickup.test.ts
Greptile caught that the example addressed Cart.ref("cart-123") while
building the runtime with createRuntime(), which deliberately leaves the
process default unset. A reader running the page as written would get
"SolidObjects.configure must be called before Actor.ref" before either
cart operation ran. Verified by running both variants against a built
package: configure() prints [ 1, 2 ], createRuntime() throws.

The example now calls configure(), which is what the browser quickstart
further down the page already does, so both examples address actors the
same way.

The pickup test used message.wait() to observe the second phase, and
wait() drives a caller worker through runOnce(). That path could have
completed the message even if the roles started by run(signal) never
claimed it, which is the one thing the test exists to prove. It now
polls message.status(), a read that processes nothing. Replacing
run(signal) with a resolved promise leaves the status at ready, so the
completion is the roles' work and nothing else.
@cardmagic

Copy link
Copy Markdown
Owner Author

Both findings were valid and are fixed in d6bfcf1.

Static reference lacks default runtime (P1). Correct, and I had this backwards: setDefaultRuntime is called by configure(), not by createRuntime(), which deliberately leaves the process default unset. I kept Cart.ref("cart-123") and changed the example to build its runtime with configure() instead, which is what the browser quickstart further down the same page already does. Both examples now address actors the same way.

Verified by running both variants against a built package rather than reasoning about it:

--- configure + Cart.ref (the fix) ---
RESULT [ 1, 2 ]
--- createRuntime + Cart.ref (what you flagged) ---
Error: SolidObjects.configure must be called before Actor.ref

Wait path masks background pickup (P2). Also correct. waitForResult calls this.callerWorker.runOnce({ activationRetention: "release" }) on every loop, so the caller path could have completed the message even if the roles started by run(signal) never claimed it, which is the one thing the test exists to prove. The test now polls message.status(), a read that processes nothing.

The test is checked in both directions:

  • Driving a worker before the first assertion: expected 'completed' to be 'ready'.
  • Replacing runtime.run(controller.signal) with a resolved promise: expected 'ready' to be 'completed', so the completion in phase two is the roles' work and nothing else.

pnpm run check clean, pnpm test 343 pass / 13 skipped.

The Ruby counterpart (cardmagic/solid-objects-ruby#52) needs neither fix: it has no configure/createRuntime split, and its test drives SolidObjects::Worker#run_until_idle directly rather than going through a wait API that assists the caller.

@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

@cardmagic
cardmagic merged commit 5caf325 into main Aug 24, 2026
19 checks passed
@cardmagic cardmagic mentioned this pull request Aug 24, 2026
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.

docs: state that background pickup requires runtime.run(signal)

1 participant