Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ on:

jobs:
go-test:
name: go vet + staticcheck + coverage
timeout-minutes: 25
runs-on: ubuntu-latest
steps:
- name: Check out
Expand Down Expand Up @@ -53,6 +55,8 @@ jobs:
awk -v total="$total" 'BEGIN { if (total + 0 < 80.0) exit 1 }'

go-test-postgres:
name: postgres-backed store tests
timeout-minutes: 25
runs-on: ubuntu-latest
services:
postgres:
Expand Down Expand Up @@ -86,6 +90,8 @@ jobs:
run: go test -race ./internal/store

go-race:
name: go test -race
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Check out
Expand All @@ -108,6 +114,8 @@ jobs:
run: go test -race ./...

console-build:
name: operator console build
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- name: Check out
Expand Down Expand Up @@ -136,6 +144,8 @@ jobs:
run: git diff --exit-code web/dist

relay-test:
name: cloudflare relay tests
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- name: Check out
Expand All @@ -156,7 +166,20 @@ jobs:
working-directory: relay
run: npm test

# Content-based, and authoritative. The Go suite used to infer staleness
# from mtimes, which a git checkout does not preserve — the committed
# bundle could look older than its source purely by checkout write order.
# This job already has Node, so it can just rebuild and compare bytes.
- name: Verify the committed relay bundle matches its source
working-directory: relay
run: |
npm run bundle
git diff --exit-code -- dist \
|| { echo "::error::relay/dist is stale — run 'npm run bundle' in relay/ and commit the result"; exit 1; }

docs-build:
name: docs site build
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- name: Check out
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/deploy-website.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ concurrency:

jobs:
deploy:
name: deploy moltnet.dev
timeout-minutes: 15
runs-on: ubuntu-latest
environment:
name: github-pages
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ permissions:

jobs:
build-assets:
name: build release tarballs
timeout-minutes: 20
runs-on: ubuntu-latest
strategy:
fail-fast: false
Expand Down Expand Up @@ -64,6 +66,8 @@ jobs:
moltnet_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz.sha256

publish-release:
name: publish GitHub release
timeout-minutes: 20
runs-on: ubuntu-latest
needs: build-assets
steps:
Expand Down
20 changes: 20 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,23 @@ Treat it as a future standalone repository that temporarily lives inside the Spa
## Extraction Constraint

If a change would make `moltnet/` harder to move into its own repository later, do not do it.

## Branches and pull requests

**Never commit to `main`.** Every change lands through a pull request, without
exception — including one-line fixes, CI configuration, documentation, and
version bumps. Work on a branch, push it, open the PR, and let CI run.

Direct commits to `main` bypass the checks that catch what local runs do not.
A zero-byte receipt store, a package that ships without its native binary, and
a two-week-red pipeline all reached `main` in this ecosystem while every local
gate was green — CI found them the first time it ran over the code.

- Branch names describe the change: `feat/…`, `fix/…`, `ci/…`, `docs/…`.
- Commit messages are conventional and single-line (`feat:`, `fix:`, `docs:`,
`ci:`, `chore:`, `refactor:`, `test:`).
- Never add co-author lines, sign-offs, or AI attributions.
- Commit as you go rather than in one batch at the end, so history shows how
the work progressed.
- Merge with a merge commit rather than a squash when the individual commits
carry meaning; squashing collapses that history irreversibly.
19 changes: 18 additions & 1 deletion internal/bridge/daimon/async.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,27 @@ func (c *Codec) StartControlAsync(ctx context.Context, client *loop.MoltnetClien
return err
}
c.receipts = newReceiptTracker(store, c.token, client, config)
go c.receipts.Run(ctx)
done := make(chan struct{})
c.asyncDone = done
go func() {
defer close(done)
c.receipts.Run(ctx)
}()
return nil
}

// WaitControlAsync blocks until the follower has returned. Every blocking call
// it makes is bound to the context StartControlAsync was given
// (`http.NewRequestWithContext` in fetch, and the same context through publish),
// so once the loop cancels it this returns promptly rather than waiting out a
// poll interval or an in-flight request.
func (c *Codec) WaitControlAsync() {
if c.asyncDone == nil {
return
}
<-c.asyncDone
}

func (c *Codec) ControlAccepted(config bridgeconfig.Config, event protocol.Event, acceptance loop.ControlAcceptance) error {
if c.receipts == nil {
return fmt.Errorf("daimon receipt follower is not running")
Expand Down
2 changes: 2 additions & 0 deletions internal/bridge/daimon/codec.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ type Codec struct {
token protocol.SecretString
receiptStorePath string
receipts *receiptTracker
// Closed when the follower goroutine StartControlAsync spawned returns.
asyncDone chan struct{}
}

type wakeRequest struct {
Expand Down
6 changes: 6 additions & 0 deletions internal/bridge/loop/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,9 @@ This package holds the generic Moltnet event loop shared by runtime adapters.
- keep this package runtime-agnostic
- keep wire contracts explicit and small
- avoid introducing dependencies back into `core/` or runtime adapters
- `RunControlLoopWithCodec` returning must mean everything it started has
stopped. A codec implementing `AsyncControlCodec` gets its context cancelled
and is then awaited through `WaitControlAsync` before the loop returns.
Cancellation is not completion: the follower it spawns can be mid-write to
durable state, so returning without the wait leaves a caller no safe point to
tear that state down. Any future async codec must honour the same barrier.
7 changes: 7 additions & 0 deletions internal/bridge/loop/control.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ func RunControlLoopWithCodec(ctx context.Context, config bridgeconfig.Config, co
if err := asyncCodec.StartControlAsync(loopCtx, client, config); err != nil {
return err
}
// Cancel BEFORE waiting, and register this after `defer cancelLoop()`
// so LIFO ordering runs it first: waiting on a follower whose context
// is still live would hang instead of shutting down.
defer func() {
cancelLoop()
asyncCodec.WaitControlAsync()
}()
}
controlClient := &http.Client{Timeout: controlRequestTimeout}
backoff := bridgeutil.NewBackoff(bridgeutil.DefaultReconnectBaseDelay, bridgeutil.DefaultReconnectMaxDelay)
Expand Down
99 changes: 99 additions & 0 deletions internal/bridge/loop/control_async_shutdown_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package loop

import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"

"github.com/gorilla/websocket"
"github.com/noopolis/moltnet/pkg/bridgeconfig"
"github.com/noopolis/moltnet/pkg/protocol"
)

// asyncShutdownCodec models what the Daimon codec does: StartControlAsync
// spawns a follower bound to the loop's context, and that follower still has
// durable work to finish after cancellation (writing its receipt store).
type asyncShutdownCodec struct {
legacyControlCodec
done chan struct{}
finished atomic.Bool
}

func (c *asyncShutdownCodec) StartControlAsync(ctx context.Context, _ *MoltnetClient, _ bridgeconfig.Config) error {
c.done = make(chan struct{})
go func() {
defer close(c.done)
<-ctx.Done()
// The window the flake lived in: cancellation is not completion, and a
// follower that is mid-write keeps writing after the loop is told to stop.
time.Sleep(20 * time.Millisecond)
c.finished.Store(true)
}()
return nil
}

func (c *asyncShutdownCodec) ControlAccepted(bridgeconfig.Config, protocol.Event, ControlAcceptance) error {
return nil
}

func (c *asyncShutdownCodec) WaitControlAsync() {
if c.done == nil {
return
}
<-c.done
}

// RunControlLoopWithCodec returning must mean every goroutine it started has
// returned. Without the barrier the loop cancels the follower and returns
// immediately, so a caller has no safe point to tear down the state that
// follower is still writing — which is how `t.TempDir()` cleanup started
// racing the Daimon receipt store.
func TestRunControlLoopWaitsForAsyncCodecBeforeReturning(t *testing.T) {
t.Parallel()

upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
moltnetServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/v1/attach" {
response.WriteHeader(http.StatusNotFound)
return
}
connection, err := upgrader.Upgrade(response, request, nil)
if err != nil {
t.Errorf("upgrade websocket: %v", err)
return
}
defer connection.Close()
writeAttachmentHandshake(t, connection, "researcher")
_ = connection.WriteControl(
websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "done"),
time.Now().Add(time.Second),
)
}))
defer moltnetServer.Close()

config := bridgeconfig.Config{
Agent: bridgeconfig.AgentConfig{ID: "researcher", Name: "Researcher"},
Moltnet: bridgeconfig.MoltnetConfig{BaseURL: moltnetServer.URL, NetworkID: "local"},
Runtime: bridgeconfig.RuntimeConfig{Kind: bridgeconfig.RuntimePi, ControlURL: moltnetServer.URL},
Rooms: []bridgeconfig.RoomBinding{{ID: "research", Wake: bridgeconfig.WakeMentions}},
}

codec := &asyncShutdownCodec{}
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()

if err := RunControlLoopWithCodec(ctx, config, codec); err != nil {
t.Fatalf("RunControlLoopWithCodec() error = %v", err)
}

if !codec.finished.Load() {
t.Fatal("RunControlLoopWithCodec returned while its async codec was still working")
}
}
12 changes: 12 additions & 0 deletions internal/bridge/loop/control_codec.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,18 @@ type DurableAcceptanceCodec interface {
type AsyncControlCodec interface {
StartControlAsync(context.Context, *MoltnetClient, bridgeconfig.Config) error
ControlAccepted(bridgeconfig.Config, protocol.Event, ControlAcceptance) error
// WaitControlAsync blocks until everything StartControlAsync spawned has
// returned. RunControlLoopWithCodec cancels the context it handed to
// StartControlAsync before calling this, so the wait is bounded by that
// cancellation rather than by the work itself.
//
// Without it the loop cancelled the follower and returned immediately, so
// RunControlLoopWithCodec returning did not mean the codec had stopped: a
// follower could still be publishing a reply and writing its durable
// receipt store after its caller believed shutdown was complete. A caller
// then has no safe point to tear down the receipt store's directory, and
// the goroutine outlives the loop that owns it.
WaitControlAsync()
}

// ControlPublicationObserver is an optional codec hook called only after a
Expand Down
3 changes: 1 addition & 2 deletions internal/machine/session_input_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"strings"
"sync/atomic"
"testing"
"time"

"github.com/noopolis/moltnet/pkg/protocol"
)
Expand Down Expand Up @@ -62,7 +61,7 @@ func TestSessionParentCancelClosesInput(t *testing.T) {

select {
case <-reader.closed:
case <-time.After(time.Second):
case <-waitTimeout(t):
t.Fatal("expected blocking reader to be closed on parent cancel")
}
}
Expand Down
3 changes: 1 addition & 2 deletions internal/machine/session_lifetime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"io"
"strings"
"testing"
"time"

"github.com/noopolis/moltnet/pkg/protocol"
)
Expand Down Expand Up @@ -34,7 +33,7 @@ func TestSessionActiveCapacity(t *testing.T) {
if response.Error == nil || response.Error.Code != protocol.MachineErrorCapacity {
t.Fatalf("expected active-capacity rejection, got %#v", response)
}
case <-time.After(2 * time.Second):
case <-waitTimeout(t):
t.Fatal("timed out waiting for active-capacity rejection")
}
close(gate)
Expand Down
33 changes: 31 additions & 2 deletions internal/machine/session_test_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,40 @@ func decodeResponses(t *testing.T, raw []byte) []protocol.MachineResponse {
return responses
}

// waitTimeout bounds a test's wait for a condition it expects to become true.
//
// These waits are COMPLETION waits, not rate assertions: nothing here asserts
// that the session is fast, only that it finishes. A fixed short deadline
// therefore does not test the product, it tests the hardware — and
// TestSessionActiveCapacity gates protocol.MachineMaxActiveRequests (512)
// goroutines, so under -race on a 2-core CI runner the legitimate drain
// exceeded the old fixed 2s and failed there while taking 0.5s here.
//
// The deadline is derived from the test binary's own -timeout so it scales with
// how long the run is already allowed to take, capped so a genuine deadlock
// still reports in a minute instead of stalling until the binary is killed.
func waitTimeout(t *testing.T) <-chan time.Time {
t.Helper()
const generous = 60 * time.Second
timeout := generous
if deadline, ok := t.Deadline(); ok {
// Leave headroom so this reports its own message rather than losing to
// the harness timeout, which would kill the binary instead.
if remaining := time.Until(deadline) - time.Second; remaining < timeout {
timeout = remaining
}
}
if timeout <= 0 {
timeout = time.Millisecond
}
return time.After(timeout)
}

func readLine(t *testing.T, ch <-chan string) {
t.Helper()
select {
case <-ch:
case <-time.After(2 * time.Second):
case <-waitTimeout(t):
t.Fatal("timed out waiting for executor")
}
}
Expand All @@ -50,7 +79,7 @@ func readErr(t *testing.T, ch <-chan error) error {
select {
case value := <-ch:
return value
case <-time.After(2 * time.Second):
case <-waitTimeout(t):
t.Fatal("timed out")
}
return nil
Expand Down
Loading
Loading