Skip to content

refactor: extract world-free runtime services - #941

Open
winton-library wants to merge 4 commits into
mainfrom
winton/tickless_meta
Open

refactor: extract world-free runtime services#941
winton-library wants to merge 4 commits into
mainfrom
winton/tickless_meta

Conversation

@winton-library

@winton-library winton-library commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Decompose Cardinal so a world-free runtime can serve the same protocol

Everything a client can observe about the CardinalService protocol lived inside pkg/cardinal, welded to a world. ADR-057 needs a tickless shard to serve that same protocol, which would have meant reimplementing it — and every disagreement between the two copies would be a client-visible bug.

Four extractions, no behavior change for existing worlds:

  • pkg/plugin/dataPlugin.Load(). Loading config never needed a world, only snapshot reconciliation did.
  • pkg/authUser, Mode, NewMiddleware, both authenticators.
  • pkg/microService.ServeCommands / Client.SendCommand. Inbound ISC was ~30 lines of validation ending in one line of runtime work.
  • pkg/shard — the CardinalService handler, behind CommandSink + EventBus.pkg/cardinal/service.go: 759 → 248 lines. Cardinal's implementation of the seam is four
    lines, and the same method now serves the ISC path, so client and inter-shard commands
    converge on one implementation instead of two that happen to agree.

Fixes a latent reply-waiter race

SendCommandWithReply registered its waiter after submitting the command. Harmless under a tick — the command waits in a queue, so the waiter always wins. A runtime that executes inside Submit publishes the reply before returning, so the event lands with nobody listening and the client blocks until timeout. The shared server registers first; regression test confirmed by restoring the old order (fails with context deadline exceeded).

Behavior changes

  • Reply-waiter ordering, above — closes a race, can't regress an existing world.
  • ISC enqueue failure now reports Internal rather than InvalidArgument. Only fires for an unregistered command name, which is unreachable through a registered endpoint.

Compatibility

cardinal.User, AuthMode, ParseAuthMode, UserFromContext remain as aliases — existing game code compiles unchanged. newService now returns an error (3 internal call sites updated).


Stack created with GitHub Stacks CLIGive Feedback 💬

@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

Claude finished @winton-library's task in 5m 37s —— View job


Code Review

  • Gather context (diff vs origin/main, original service.go, repo conventions)
  • Review pkg/auth extraction
  • Review pkg/micro/isc.go
  • Review pkg/shard/server.go
  • Review pkg/plugin/data + pkg/cardinal call-site updates
  • Verify build/tests
  • Post findings (4 inline comments)

I read the extracted code against the pre-refactor pkg/cardinal/service.go line by line. The move is faithful — every behavior change is either listed in the description or one I'd call an improvement. Server.accept is a genuine consolidation rather than a copy: the address check, persona stamping, and submit now exist once and both entry points reach them. micro.ServeCommands owning the shard-persona check means a runtime can't forget it, and Client.SendCommand now builds the iscv1.Command in the same file that parses it, so the subject convention and the message shape can't drift. The reply-waiter reordering is a real fix, and pinning it in pkg/shard rather than leaving each runtime to rediscover it is the right call.

Four findings, all posted inline. Three of them are pre-existing behavior carried over verbatim — I'm raising them because this PR is what promotes pkg/shard from "Cardinal's implementation" to "the normative protocol with a conformance suite," which makes it the moment these get decided rather than inherited.

# File Finding
1 pkg/shard/server.go:409-447 Reply waiters ignore Recipient. subscribers is filtered by evt.Recipient; waiters is not. A directed event for user A is delivered to user B's in-flight SendCommandWithReply if B waits on the same event name. Pre-existing.
2 pkg/shard/server.go:162-164 Client and ISC paths disagree on sink failures. This PR moved ISC to Internal; the client path keeps InvalidArgument. Unreachable under Cardinal today, but it's exactly the transactional-sink case the refactor exists for — and the conformance suite pins it.
3 pkg/shard/server.go:369-395 TOCTOU between hasSubscriber and the map mutation. A client disconnecting mid-subscribe panics the handler (assert.That, or a nil deref under -tags release). Pre-existing.
4 pkg/cardinal/service_internal_test.go:108-117 Payload round-trip integrity no longer asserted. The replaced test decoded the delivered bytes; this one only checks for a nil error.

Smaller things, not worth inline comments:

  • EventBus.PublishEvent returns an error that is unconditionally nil (server.go:476). Every caller has to handle a failure that can't happen; either drop the return or make delivery failures reportable.
  • pkg/shard imports pkg/micro for ServiceAddress and String alone, which puts the NATS client into the dependency graph of a package whose whole premise is that it needs no runtime. Worth watching if ServiceAddress ever moves somewhere lighter.
  • Handler() builds its own otel interceptor and mountDebugService builds another; the two services shared one before. Harmless, just no longer shared.
  • ServeCommands also registers the ping endpoint. Documented, but the name doesn't suggest it, and a runtime can't take one without the other.
  • Plugin.resolverSource() is now called per reconcile tick instead of being captured once. EmbedSource is a one-field struct, so this is free — noting it only because it's a behavioral difference in the diff.

Verification

  • go build ./... — clean
  • go vet on all five touched packages — clean
  • go test ./pkg/shard/... ./pkg/auth/... ./pkg/micro/... ./pkg/plugin/data/... ./pkg/cardinal/... — all pass
  • go test -race ./pkg/shard/... ./pkg/auth/... — pass
  • gofmt -l — clean
  • go test -race ./pkg/micro/... fails in TestClient_ReconnectsAfterServerRestart, but every frame of the race is inside nats.go@v1.49.0, and reconnect_internal_test.go isn't touched here. Appears pre-existing and unrelated; flagging in case it's news.
  • golangci-lint isn't installed on this runner, so repo lint rules weren't checked.
    • branch winton/tickless_meta

@capy-ai capy-ai Bot changed the title winton/tickless meta refactor: extract world-free runtime services Aug 14, 2026

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

View 2 other findings in Capy.

Open in Capy Review

Comment thread pkg/shard/server.go
Comment on lines +409 to +447
// PublishEvent delivers evt to everyone waiting for it: any SendCommandWithReply call awaiting an
// event of this name, and every stream subscriber whose subscriptions match it.
//
// A subscriber whose stream is closed or failing is skipped and logged rather than failing the
// call, since one broken client must not stop an event reaching the rest.
//
//nolint:gocognit // Put everything here so you can understand the logic in one place.
func (s *Server) PublishEvent(evt Event) error {
eventPb := &iscv1.Event{
Name: evt.Name,
Payload: evt.Payload,
}

s.mu.RLock()
var subscribers []*streamSubscriber
//nolint:nestif // It's fine
if evt.Recipient != "" {
if subscriber, exists := s.subscribers[evt.Recipient]; exists {
for subscription := range subscriber.events {
if matchesEvent(subscription, eventPb.GetName()) {
subscribers = []*streamSubscriber{subscriber}
break
}
}
} else {
s.log.Debug().Str("recipient", evt.Recipient).Str("event", eventPb.GetName()).Msg("recipient has no open stream")
}
} else {
subscribers = make([]*streamSubscriber, 0, len(s.subscribers))
for _, subscriber := range s.subscribers {
for subscription := range subscriber.events {
if matchesEvent(subscription, eventPb.GetName()) {
subscribers = append(subscribers, subscriber)
break
}
}
}
}
waiters := append([]chan *iscv1.Event(nil), s.replyWaiters[eventPb.GetName()]...)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reply waiters ignore Recipient — a directed event reaches other users' in-flight replies

subscribers is filtered by evt.Recipient (lines 425–446), but waiters is not: every in-flight SendCommandWithReply registered under this event name gets the payload, no matter who the event was addressed to.

Failure scenario: user B calls SendCommandWithReply{EventName: "loot_awarded"} and blocks. Concurrently the runtime publishes Event{Name: "loot_awarded", Recipient: "A"} for user A. B's waiter receives A's payload and returns it as B's reply. Event names are part of a game's public schema, so B doesn't need to guess anything.

This is carried over verbatim from pkg/cardinal/service.go on main, so it isn't a regression — but this PR is what makes pkg/shard the single normative definition of the protocol and adds a conformance suite for it, which makes it the right moment to either fix it or pin it deliberately. A recipient field on the waiter, compared against evt.Recipient (empty = broadcast, matches all), would close it.

Fix this →

Comment thread pkg/shard/server.go
Comment on lines +162 to +164
if err := s.commands.Submit(ctx, cmd); err != nil {
return connect.NewError(connect.CodeInvalidArgument, eris.Wrap(err, "failed to enqueue command"))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two entry points still disagree on how a sink failure is reported

This PR deliberately moved the ISC path to codes.Internal (pkg/micro/isc.go:92) on the reasoning that a runtime refusing a command is not a malformed request. The client path keeps CodeInvalidArgument for the exact same Submit error.

Under Cardinal today this is unreachable in practice (Enqueue only errors on an unregistered name), so nothing regresses. But CommandSink's own doc — "running it now inside a transaction" — is the case this refactor exists to enable, and there Submit fails for storage errors, aborted transactions, backpressure. All of those would be reported to the client as InvalidArgument: a permanent, don't-retry code for a transient server-side fault, and a different code than the same failure gets over ISC.

A seam that lets the sink pick works nicely, since connect.CodeOf returns CodeUnknown for anything that isn't already a *connect.Error:

if err := s.commands.Submit(ctx, cmd); err != nil {
    if connect.CodeOf(err) != connect.CodeUnknown {
        return err // the sink chose its own code
    }
    return connect.NewError(connect.CodeInternal, eris.Wrap(err, "failed to accept command"))
}

Whichever way you go, TestSendCommand_SinkErrorRejectsRequest (server_internal_test.go:128) currently pins InvalidArgument for a sink that returns "runtime refused", so the conformance suite is asserting the behavior in question.

Comment thread pkg/shard/server.go
Comment on lines +369 to +395
func (s *Server) subscribeEvents(user *auth.User, subscriptions []*cardinalv1.EventSubscription) {
s.mu.Lock()
defer s.mu.Unlock()

subscriber := s.subscribers[user.ID]
assert.That(subscriber != nil, "subscriber should exist for authenticated stream")

for _, subscription := range subscriptions {
for _, eventName := range subscription.GetEvents() {
subscriber.events[eventName] = struct{}{}
}
}
}

func (s *Server) unsubscribeEvents(user *auth.User, subscriptions []*cardinalv1.EventSubscription) {
s.mu.Lock()
defer s.mu.Unlock()

subscriber := s.subscribers[user.ID]
assert.That(subscriber != nil, "subscriber should exist for authenticated stream")

for _, subscription := range subscriptions {
for _, eventName := range subscription.GetEvents() {
delete(subscriber.events, eventName)
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TOCTOU: the hasSubscriber check and the map mutation don't share a lock

SubscribeEvents/UnsubscribeEvents take RLock for hasSubscriber (line 300 / 319), release it, then take Lock here. If the client's StartEventStream returns in that window, defer s.removeSubscriber(user) has already deleted the entry and subscriber is nil.

In a normal build assert.That panics (pkg/assert panics on !cond); with -tags release it's a no-op and line 378 nil-derefs instead. Either way it's a panic in a request handler triggered by an ordinary client disconnect racing its own subscribe — net/http recovers it, but the connection dies and the assertion's "should exist" invariant simply isn't one.

Carried over from pkg/cardinal/service.go, so not a regression. Cheap to make actually true, though: have subscribeEvents/unsubscribeEvents do the lookup under their own write lock and return false when the subscriber is gone, and drop the separate hasSubscriber pre-check — that also removes the double locking.

Comment on lines +108 to +117
t.Run("marshals and publishes", func(t *testing.T) {
t.Parallel()
prng := testutils.NewRand(t)
fixture := newServiceFixture(t, prng, false)

payload := testutils.SimpleEvent{Value: prng.Int()}
waiter := fixture.svc.addReplyWaiter(payload.Name())
defer fixture.svc.removeReplyWaiter(payload.Name(), waiter)
require.NoError(t, fixture.svc.publishDefaultEvent(event.Event{
Kind: event.KindDefault,
Payload: testutils.SimpleEvent{Value: prng.Int()},
}))
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Payload round-trip integrity is no longer asserted anywhere

The test this replaces registered a waiter, published, and then decoded the delivered bytes back through SimpleEvent{}.UnmarshalWire and compared them to the original. This version only asserts publishDefaultEvent returns nil, so it would pass if MarshalWire emitted bytes no client could decode.

The pkg/shard suite doesn't cover the gap either — it proves opaque []byte travels from PublishEvent to a waiter, which is the right split, but nobody now checks that publishDefaultEvent's marshaling is what a client can read. Keeping the decode-and-compare here (against s.shardServer.addReplyWaiter, or by asserting on the shard.Event handed to a stub EventBus) restores it without pulling protocol assertions back into pkg/cardinal.

@winton-library winton-library self-assigned this Aug 14, 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.

1 participant