carlos / platform Public

host: large-box startup test, review round 1 (#281 ask 2)

Astra's findings on 671a338f, all in the test:

- TestRunAgentStartupWiring reads runAgent's source and fails if its
  wiring drifts from what the child rebuilds: the latch set, Run given
  startupCtx, the edge, then cancel, join and Shutdown in that order; the
  activatorStart fields the child copies; Recover's three calls in order;
  the boot pass gated on started or startupCtx. It also fails if anything
  before the edge receives from a channel, joins startup, or calls a
  closure that waits on the latch outside the claim-box branch.
  Sharing one orchestration function is left as a follow-up.
- Every admission kind the latch gates now arrives during startup and is
  asserted to complete only after it opens: a sidecar-marked request, a
  worker (runAgent's boot pass and a direct WakeWorker, coalesced on one
  child that reports ready), and a scheduled delivery through the real
  schedule.Runner and activatorDeliverer.
- A checkpoint in Start asserts every held host is already fenced again
  and every journal complete when the activator starts.
- The TLS listener is awaited on its own, boundedly, after the gate probe
  on the plain-HTTP listener.
- Ports: the edge cannot report a :0 port, so a collision is detected
  from the agent's log and the agent restarted on fresh ports.
- The socket root tries TMPDIR, then its own directory under /tmp, and
  skips with the reason when neither is short enough.
- The agent's log file is closed by a cleanup registered as it is made.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 7e16dd018f673a1afc6d5d4df55a060803faa1e6 parent 671a338
1 file changed, +569 −94
  • internal/host/startup_largebox_linux_test.go +569 −94

CI passed — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok  	github.com/carlosframework/platform	333.773s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	4.537s
ok  	github.com/carlosframework/platform/cmd/moveprobe	92.218s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.381s
ok  	github.com/carlosframework/platform/internal/activator	228.469s
ok  	github.com/carlosframework/platform/internal/agentsock	0.020s
ok  	github.com/carlosframework/platform/internal/alerting	0.004s
ok  	github.com/carlosframework/platform/internal/analytics	0.018s
ok  	github.com/carlosframework/platform/internal/appconfig	2.285s
ok  	github.com/carlosframework/platform/internal/apperr	0.081s
ok  	github.com/carlosframework/platform/internal/applog	0.132s
ok  	github.com/carlosframework/platform/internal/awsinv	0.007s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.004s
ok  	github.com/carlosframework/platform/internal/boxid	0.626s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.008s
ok  	github.com/carlosframework/platform/internal/caps	0.012s
ok  	github.com/carlosframework/platform/internal/certwarm	0.002s
ok  	github.com/carlosframework/platform/internal/cgroup	0.697s
ok  	github.com/carlosframework/platform/internal/changelog	0.310s
ok  	github.com/carlosframework/platform/internal/clamd	0.054s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	361.712s
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	6.098s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.009s
ok  	github.com/carlosframework/platform/internal/dbverify	0.004s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.003s
ok  	github.com/carlosframework/platform/internal/docsite	0.005s
ok  	github.com/carlosframework/platform/internal/domains	2.093s
ok  	github.com/carlosframework/platform/internal/doorkey	0.006s
ok  	github.com/carlosframework/platform/internal/economics	0.003s
ok  	github.com/carlosframework/platform/internal/edge	26.217s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.009s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.138s
ok  	github.com/carlosframework/platform/internal/gate	1.655s
ok  	github.com/carlosframework/platform/internal/genselect	0.015s
ok  	github.com/carlosframework/platform/internal/geo	0.017s
ok  	github.com/carlosframework/platform/internal/geoip	0.030s
ok  	github.com/carlosframework/platform/internal/host	125.177s
ok  	github.com/carlosframework/platform/internal/hostactors	0.374s
ok  	github.com/carlosframework/platform/internal/hostbox	2.114s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.542s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.007s
ok  	github.com/carlosframework/platform/internal/hostexec	4.672s
ok  	github.com/carlosframework/platform/internal/hostfence	0.030s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.436s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.012s
ok  	github.com/carlosframework/platform/internal/hostguard	0.966s
ok  	github.com/carlosframework/platform/internal/hostops	1.447s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.002s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.005s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.469s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.132s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.002s
ok  	github.com/carlosframework/platform/internal/infraview	0.002s
ok  	github.com/carlosframework/platform/internal/instancectl	1.812s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.701s
ok  	github.com/carlosframework/platform/internal/instancefs	0.023s
ok  	github.com/carlosframework/platform/internal/instancekey	0.016s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.023s
ok  	github.com/carlosframework/platform/internal/layout	1.444s
ok  	github.com/carlosframework/platform/internal/lease	9.448s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.007s
ok  	github.com/carlosframework/platform/internal/ledger	0.027s
ok  	github.com/carlosframework/platform/internal/mail	0.152s
ok  	github.com/carlosframework/platform/internal/maintenance	0.077s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.510s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.061s
ok  	github.com/carlosframework/platform/internal/metrics	0.005s
ok  	github.com/carlosframework/platform/internal/moves	21.086s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.761s
ok  	github.com/carlosframework/platform/internal/objstore	0.174s
ok  	github.com/carlosframework/platform/internal/ops	0.161s
ok  	github.com/carlosframework/platform/internal/park	0.203s
ok  	github.com/carlosframework/platform/internal/privatefile	0.004s
ok  	github.com/carlosframework/platform/internal/provision	7.375s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.006s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	4.007s
ok  	github.com/carlosframework/platform/internal/relay	7.224s
ok  	github.com/carlosframework/platform/internal/release	19.453s
ok  	github.com/carlosframework/platform/internal/rollout	3.616s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.004s
ok  	github.com/carlosframework/platform/internal/rootaws	0.008s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.009s
ok  	github.com/carlosframework/platform/internal/rootstate	0.026s
ok  	github.com/carlosframework/platform/internal/s3compat	0.005s
ok  	github.com/carlosframework/platform/internal/safefile	0.006s
ok  	github.com/carlosframework/platform/internal/schedule	2.287s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	8.340s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.248s
ok  	github.com/carlosframework/platform/internal/status	0.041s
ok  	github.com/carlosframework/platform/internal/steering	0.006s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.319s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.002s
ok  	github.com/carlosframework/platform/internal/updatewords	0.004s
ok  	github.com/carlosframework/platform/internal/usage	0.004s
ok  	github.com/carlosframework/platform/internal/version	0.001s
ok  	github.com/carlosframework/platform/internal/vet	1.548s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: cached, digest verified
  mc: cached, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.022s
=== RUN   TestT4StorageProbeMinio
2026/10/13 22:17:25 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE36B340F4B381, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.020s
minio contract: PASS
diff --git a/internal/host/startup_largebox_linux_test.go b/internal/host/startup_largebox_linux_test.go
index 7e893e684..b23550fa0 100644
--- a/internal/host/startup_largebox_linux_test.go
+++ b/internal/host/startup_largebox_linux_test.go
@@ -24,17 +24,25 @@ package host
// restoration are the real pass over real journals, hold markers and
// files in a temp data dir, Start is the real activator's, and the
// edge is edge.RunWithActivatorReady in its production (TLS) mode;
+// - every kind of admission the latch gates arrives during startup: an
+// instance request, a sidecar-marked request, a worker wake (from
+// runAgent's boot pass and from a direct call), and a scheduled
+// delivery through the real schedule runner and deliverer;
// - the activator's backend is a fake that serves an app on the row's
-// socket. It and the key pass's writer are the only fakes in the
-// agent, and neither is a startup step.
+// socket and starts workers that report ready. It and the key pass's
+// writer are the only fakes in the agent, and neither is a startup
+// step.
//
// The child's startup is runAgent's legacy-box sequence: the same
// activatorStart, with runAgent's Sweep and Recover closures. runAgent
// itself cannot run in a test (its control socket path is a constant,
// it builds AWS clients, and it log.Fatals), so the child assembles the
-// same pieces in the same order. Two box facts differ from production
-// and are set on the pass: its socket directory is the fixture's, not
-// /run/carlos, and it has no carlos-sockets group.
+// same pieces in the same order, and TestRunAgentStartupWiring reads
+// runAgent's source to fail if the two drift apart. Sharing one
+// orchestration function between them is a follow-up (#281). Two box
+// facts differ from production and are set on the pass: its socket
+// directory is the fixture's, not /run/carlos, and it has no
+// carlos-sockets group.
//
// The child reports what it does as timestamped lines in an events file;
// the test reads them to order "a request reached the activator", "the
@@ -54,6 +62,10 @@ import (
"encoding/pem"
"errors"
"fmt"
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "go/types"
"io"
"log"
"math/big"
@@ -79,6 +91,7 @@ import (
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/schedule"
"github.com/carlosframework/platform/internal/version"
)
@@ -87,10 +100,14 @@ const (
// is the agent and nothing else.
largeBoxAgentEnv = "CARLOS_TEST_LARGEBOX_AGENT"
- largeBoxRoutes = 300 // the box that failed had 142
- largeBoxJournals = 8 // hosts 0..7: a layout move was in flight at the restart
- largeBoxHolds = 4 // hosts 8..11: held down after a refused move
- largeBoxWakeHost = 12 // an ordinary sleeping instance
+ largeBoxRoutes = 300 // instance routes; the box that failed had 142
+ largeBoxJournals = 8 // hosts 0..7: a layout move was in flight at the restart
+ largeBoxHolds = 4 // hosts 8..11: held down after a refused move
+ largeBoxWakeHost = 12 // an ordinary sleeping instance, woken by a request
+ largeBoxDueHost = 13 // a sleeping instance with a scheduled delivery due
+ largeBoxSidecarHost = 14 // a sleeping instance its sidecar calls
+ largeBoxWorker = "worker-000.example.com"
+ largeBoxDuePath = "/jobs/tick"
// largeBoxListenBound is how soon the gate's probe must be answered
// after the agent process starts. The gate allows 20 s; the edge's own
@@ -110,6 +127,9 @@ const (
// requests before it returns.
largeBoxJoinBound = 5 * time.Second
largeBoxDrain = 10 * time.Second
+ // largeBoxBindAttempts is how often the agent is started on fresh ports
+ // when one it was handed is taken before it binds (see startAgent).
+ largeBoxBindAttempts = 3
)
func largeBoxHost(i int) string { return fmt.Sprintf("app-%03d.example.com", i) }
@@ -140,38 +160,45 @@ func TestStartupLargeBoxEndToEnd(t *testing.T) {
// testLargeBoxColdControl is the 2026-10-13 shape: control restarted just
// before the agent, and slow to answer. The edge must answer the gate's
-// probe while the sweep waits, hold every instance request at the latch
-// until sweep, recovery and hold restoration are done, and then serve each
-// host only where it is safe to.
+// probe while the sweep waits, hold every admission at the latch until
+// sweep, recovery and hold restoration are done, and then serve each host
+// only where it is safe to.
func testLargeBoxColdControl(t *testing.T) {
b := newLargeBox(t)
a := b.startAgent(t)
- listen := a.waitListening(t)
- t.Logf("gate probe answered %s after the agent started, %d routes, instance control cold", listen, largeBoxRoutes)
+ t.Logf("gate probe answered %s after the agent started, %d routes, instance control cold", a.listen, largeBoxRoutes)
if _, ok := a.event("ready"); ok {
t.Fatal("the startup latch opened while instance control was still cold")
}
- wake, held, moved := largeBoxHost(largeBoxWakeHost), largeBoxHost(largeBoxJournals), largeBoxHost(0)
+ wake, held, moved, due, sidecar := largeBoxHost(largeBoxWakeHost), largeBoxHost(largeBoxJournals), largeBoxHost(0), largeBoxHost(largeBoxDueHost), largeBoxHost(largeBoxSidecarHost)
flatMoved := filepath.Join(b.socketDir, moved+".sock")
- wakeAns, heldAns, movedAns := b.fire(wake), b.fire(held), b.fire(moved)
+ wakeAns, heldAns, movedAns := b.fire(wake, false), b.fire(held, false), b.fire(moved, false)
+ sidecarAns := b.fire(sidecar, true)
enterWake := a.waitEvent(t, 10*time.Second, "enter", wake)
a.waitEvent(t, 10*time.Second, "enter", held)
+ enterSidecar := a.waitEvent(t, 10*time.Second, "enter", sidecar)
enterMoved := a.waitEvent(t, 10*time.Second, "enter", moved)
if enterMoved.arg(1) != flatMoved {
t.Fatalf("the edge routed %s to %q before recovery, want its pre-move socket %q", moved, enterMoved.arg(1), flatMoved)
}
- // Every request is now inside the activator, and startup is not done.
+ // The agent's own admissions: a direct worker wake and the scheduled
+ // tick, both started before the edge listened.
+ workerAsked := a.waitEvent(t, 10*time.Second, "worker-direct-begin")
+ ticked := a.waitEvent(t, 10*time.Second, "tick-done")
+ // Every admission is now waiting, and startup is not done.
time.Sleep(200 * time.Millisecond)
- for host, ch := range map[string]chan largeBoxAnswer{wake: wakeAns, held: heldAns, moved: movedAns} {
+ for host, ch := range map[string]chan largeBoxAnswer{wake: wakeAns, held: heldAns, moved: movedAns, sidecar: sidecarAns} {
select {
case got := <-ch:
t.Fatalf("%s answered before startup finished: %d %q %v", host, got.status, got.body, got.err)
default:
}
}
- if evs := a.events("acquire"); len(evs) != 0 {
- t.Fatalf("a wake was admitted while instance control was cold: %v", evs)
+ for _, kind := range []string{"acquire", "start", "worker-start", "served", "worker-direct", "worker-boot", "activator-start"} {
+ if evs := a.events(kind); len(evs) != 0 {
+ t.Fatalf("%s while instance control was cold: %v", kind, evs)
+ }
}
// Instance control warms: the sweep stops the 300 leftovers, then
@@ -185,8 +212,17 @@ func testLargeBoxColdControl(t *testing.T) {
if got := b.control.stops.Load(); got != largeBoxRoutes {
t.Errorf("the sweep stopped %d units, want %d", got, largeBoxRoutes)
}
- if !enterWake.at.Before(ready.at) {
- t.Fatal("the wake did not arrive before the latch opened; the test proves nothing about the latch")
+ for what, at := range map[string]time.Time{"wake": enterWake.at, "sidecar request": enterSidecar.at, "worker wake": workerAsked.at, "scheduled tick": ticked.at} {
+ if !at.Before(ready.at) {
+ t.Fatalf("the %s did not arrive before the latch opened; the test proves nothing about its latch", what)
+ }
+ }
+
+ // Start ran only once the held hosts were fenced again and every
+ // journal was complete.
+ st := a.waitEvent(t, time.Second, "activator-start")
+ if st.arg(0) != "held="+strconv.Itoa(largeBoxHolds) || st.arg(1) != "fenced="+strconv.Itoa(largeBoxHolds) || st.arg(2) != "journals=0" || st.at.After(ready.at) {
+ t.Fatalf("at Start: %v", st)
}
// The wake was held, then served.
@@ -194,11 +230,27 @@ func testLargeBoxColdControl(t *testing.T) {
if got.status != http.StatusOK || got.body != "host="+wake+" socket="+filepath.Join(b.socketDir, wake+".sock") {
t.Fatalf("held wake after startup: %d %q %v", got.status, got.body, got.err)
}
- acq := a.waitEvent(t, time.Second, "acquire", wake)
- if acq.at.Before(ready.at) {
- t.Fatalf("%s was acquired %s before the latch opened", wake, ready.at.Sub(acq.at))
+ a.assertAfter(t, ready, "acquire", wake)
+
+ // The sidecar's call was held too, then answered as the activator
+ // answers a sidecar whose instance sleeps: it does not wake it.
+ got = b.answer(t, sidecarAns)
+ if got.status != http.StatusServiceUnavailable || got.body != "instance is asleep; sidecar requests do not wake it\n" || got.at.Before(ready.at) {
+ t.Fatalf("sidecar request held through startup: %d %q %v at %s, latch at %s", got.status, got.body, got.err, got.at, ready.at)
+ }
+
+ // The worker: both wakes waited, then coalesced on one child.
+ a.assertAfter(t, ready, "worker-direct", largeBoxWorker, "true")
+ a.assertAfter(t, ready, "worker-boot", largeBoxWorker, "true")
+ a.assertAfter(t, ready, "worker-start", largeBoxWorker)
+ if n := len(a.events("worker-start")); n != 1 {
+ t.Errorf("worker started %d times, want once", n)
}
+ // The scheduled delivery waited, then woke its instance and ran.
+ a.assertAfter(t, ready, "served", due, largeBoxDuePath)
+ a.assertAfter(t, ready, "acquire", due)
+
// The held host was not started: its restored hold answers maintenance.
got = b.answer(t, heldAns)
if got.status != http.StatusServiceUnavailable || got.body != "instance failed to wake\n" || got.header.Get("Cache-Control") != "no-store" {
@@ -216,11 +268,11 @@ func testLargeBoxColdControl(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- got = b.get(moved)
+ got = b.get(moved, false)
if got.status != http.StatusOK || got.body != "host="+moved+" socket="+nested.To.Socket {
t.Fatalf("retry of the moved host: %d %q %v", got.status, got.body, got.err)
}
- got = b.get(held)
+ got = b.get(held, false)
if got.status != http.StatusServiceUnavailable || got.body != "instance failed to wake\n" {
t.Fatalf("retry of the held host: %d %q %v", got.status, got.body, got.err)
}
@@ -252,12 +304,11 @@ func testLargeBoxColdControl(t *testing.T) {
func testLargeBoxOverrun(t *testing.T) {
b := newLargeBox(t)
a := b.startAgent(t)
- listen := a.waitListening(t)
- t.Logf("gate probe answered %s after the agent started, instance control never warms", listen)
+ t.Logf("gate probe answered %s after the agent started, instance control never warms", a.listen)
wake := largeBoxHost(largeBoxWakeHost)
began := time.Now()
- got := b.get(wake)
+ got := b.get(wake, false)
took := time.Since(began)
t.Logf("wake held %s by a startup that does not finish", took)
if got.err != nil {
@@ -269,13 +320,8 @@ func testLargeBoxOverrun(t *testing.T) {
if took < largeBoxLatchBound-time.Second || took > largeBoxLatchBound+5*time.Second {
t.Fatalf("wake answered after %s, want the latch bound %s", took, largeBoxLatchBound)
}
- if _, ok := a.event("ready"); ok {
- t.Fatal("the latch opened with instance control cold")
- }
+ a.assertNeverStarted(t)
b.assertNoWrongService(t, a)
- if evs := a.events("acquire"); len(evs) != 0 {
- t.Fatalf("a wake was admitted during an overrunning startup: %v", evs)
- }
// Shutting down with the sweep still waiting: nothing in flight, so the
// edge returns at once and runAgent's join bounds the rest.
@@ -283,6 +329,7 @@ func testLargeBoxOverrun(t *testing.T) {
a.signal(t, syscall.SIGTERM)
a.waitExit(t, largeBoxJoinBound+3*time.Second)
t.Logf("agent exited %s after SIGTERM with the sweep still waiting", time.Since(at))
+ a.assertNeverStarted(t)
a.assertCleanShutdown(t)
}
@@ -293,10 +340,9 @@ func testLargeBoxOverrun(t *testing.T) {
func testLargeBoxShutdownMidStartup(t *testing.T) {
b := newLargeBox(t)
a := b.startAgent(t)
- a.waitListening(t)
wake := largeBoxHost(largeBoxWakeHost)
- ans := b.fire(wake)
+ ans := b.fire(wake, false)
a.waitEvent(t, 10*time.Second, "enter", wake)
at := time.Now()
@@ -310,16 +356,262 @@ func testLargeBoxShutdownMidStartup(t *testing.T) {
if got.err != nil || got.status != http.StatusServiceUnavailable {
t.Fatalf("wake held through shutdown: %d %q %v", got.status, got.body, got.err)
}
- if _, ok := a.event("ready"); ok {
- t.Fatal("the latch opened during shutdown")
- }
+ a.assertNeverStarted(t)
b.assertNoWrongService(t, a)
- if evs := a.events("acquire"); len(evs) != 0 {
- t.Fatalf("a wake was admitted during shutdown: %v", evs)
- }
a.assertCleanShutdown(t)
}
+// --- runAgent's wiring ------------------------------------------------------------
+
+// TestRunAgentStartupWiring reads runAgent's source and fails if its
+// startup wiring drifts from what runLargeBoxAgent rebuilds, or if
+// anything on runAgent's path to the edge waits for startup to finish.
+// It checks structure, not text, so comments, formatting and unrelated
+// statements can change freely.
+func TestRunAgentStartupWiring(t *testing.T) {
+ fset := token.NewFileSet()
+ var fn *ast.FuncDecl
+ files, err := filepath.Glob("*.go")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range files {
+ if strings.HasSuffix(name, "_test.go") {
+ continue
+ }
+ f, err := parser.ParseFile(fset, name, nil, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, d := range f.Decls {
+ if fd, ok := d.(*ast.FuncDecl); ok && fd.Recv == nil && fd.Name.Name == "runAgent" {
+ fn = fd
+ }
+ }
+ }
+ if fn == nil {
+ t.Fatal("runAgent not found in package host")
+ }
+ str := types.ExprString
+
+ // The calls and statements this test anchors on, by position.
+ var (
+ run *ast.CallExpr // activatorStart{...}.Run(startupCtx)
+ lit *ast.CompositeLit // its activatorStart literal
+ edgeCall *ast.CallExpr // edge.RunWithActivatorReady(...)
+ latch token.Pos // svc.Startup = started
+ cancels []token.Pos // cancelStartup() (a defer of it comes first)
+ joins []token.Pos // joinAgentStartup(..., startupDone)
+ stops []token.Pos // svc.Shutdown(...)
+ bootPass *ast.GoStmt // the go statement running runWorkerBootPass
+ waiters = map[string]bool{} // local closures that wait on the latch
+ )
+ ast.Inspect(fn.Body, func(n ast.Node) bool {
+ switch n := n.(type) {
+ case *ast.CallExpr:
+ switch s := str(n.Fun); {
+ case s == "edge.RunWithActivatorReady":
+ edgeCall = n
+ case s == "cancelStartup":
+ cancels = append(cancels, n.Pos())
+ case s == "joinAgentStartup" && len(n.Args) == 2 && str(n.Args[1]) == "startupDone":
+ joins = append(joins, n.Pos())
+ case s == "svc.Shutdown":
+ stops = append(stops, n.Pos())
+ }
+ if sel, ok := n.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "Run" {
+ if cl, ok := sel.X.(*ast.CompositeLit); ok && str(cl.Type) == "activatorStart" {
+ run, lit = n, cl
+ }
+ }
+ case *ast.AssignStmt:
+ if len(n.Lhs) == 1 && len(n.Rhs) == 1 && str(n.Lhs[0]) == "svc.Startup" && str(n.Rhs[0]) == "started" {
+ latch = n.Pos()
+ }
+ if len(n.Lhs) == 1 && len(n.Rhs) == 1 {
+ if fl, ok := n.Rhs[0].(*ast.FuncLit); ok && receivesFrom(fl.Body, "started") {
+ waiters[str(n.Lhs[0])] = true
+ }
+ }
+ case *ast.GoStmt:
+ if fl, ok := n.Call.Fun.(*ast.FuncLit); ok && callsNamed(fl.Body, "runWorkerBootPass") {
+ bootPass = n
+ }
+ }
+ return true
+ })
+ if run == nil || edgeCall == nil {
+ t.Fatalf("runAgent: activatorStart{...}.Run found %v, edge.RunWithActivatorReady found %v", run != nil, edgeCall != nil)
+ }
+
+ // The order the child copies: the latch set, the start sequence handed
+ // the startup context, the edge, then cancel, join, Shutdown (the first
+ // of each after the edge returns).
+ afterEdge := func(ps []token.Pos) token.Pos {
+ for _, p := range ps {
+ if p > edgeCall.Pos() {
+ return p
+ }
+ }
+ return token.NoPos
+ }
+ cancel, join, shutdown := afterEdge(cancels), afterEdge(joins), afterEdge(stops)
+ if len(run.Args) != 1 || str(run.Args[0]) != "startupCtx" {
+ t.Errorf("activatorStart.Run is given %v, want startupCtx", run.Args)
+ }
+ order := []struct {
+ name string
+ pos token.Pos
+ }{
+ {"svc.Startup = started", latch}, {"activatorStart{...}.Run", run.Pos()}, {"edge.RunWithActivatorReady", edgeCall.Pos()},
+ {"cancelStartup()", cancel}, {"joinAgentStartup(_, startupDone)", join}, {"svc.Shutdown", shutdown},
+ }
+ for i, o := range order {
+ if o.pos == token.NoPos {
+ t.Errorf("runAgent: %s not found", o.name)
+ } else if i > 0 && order[i-1].pos != token.NoPos && o.pos < order[i-1].pos {
+ t.Errorf("runAgent: %s comes before %s", o.name, order[i-1].name)
+ }
+ }
+
+ // The activatorStart fields the child copies.
+ fields := map[string]ast.Expr{}
+ for _, el := range lit.Elts {
+ if kv, ok := el.(*ast.KeyValueExpr); ok {
+ fields[str(kv.Key)] = kv.Value
+ }
+ }
+ for field, want := range map[string]string{
+ "Start": "svc.Start", "Publish": "armedLayoutPass.Store", "Slot": "svc.PrepareScheduledToken",
+ "Routes": "reg.List", "Route": "reg.Get", "Claims": "claims.Writes.Enabled",
+ } {
+ if got := fields[field]; got == nil || str(got) != want {
+ t.Errorf("activatorStart.%s is %v, want %s", field, exprOrNil(got), want)
+ }
+ }
+ funcField := func(name string) *ast.FuncLit {
+ fl, _ := fields[name].(*ast.FuncLit)
+ if fl == nil {
+ t.Errorf("activatorStart.%s is not a func literal", name)
+ }
+ return fl
+ }
+ if fl := funcField("Ready"); fl != nil && !callsWith(fl.Body, "close", "started") {
+ t.Error("activatorStart.Ready does not close started")
+ }
+ if fl := funcField("Sweep"); fl != nil && !callsNamed(fl.Body, "eb.Control.Sweep") {
+ t.Error("activatorStart.Sweep does not call eb.Control.Sweep")
+ }
+ if fl := funcField("Recover"); fl != nil {
+ var seq []string
+ ast.Inspect(fl.Body, func(n ast.Node) bool {
+ if c, ok := n.(*ast.CallExpr); ok {
+ switch s := str(c.Fun); s {
+ case "newLayoutPass", "startupRecoveryIntent", "recoverLayoutAtStart":
+ seq = append(seq, s)
+ }
+ }
+ return true
+ })
+ if strings.Join(seq, ",") != "newLayoutPass,startupRecoveryIntent,recoverLayoutAtStart" {
+ t.Errorf("activatorStart.Recover calls %v, want newLayoutPass, startupRecoveryIntent, recoverLayoutAtStart in that order", seq)
+ }
+ }
+
+ // The worker boot pass waits on the latch or the startup context, then
+ // lists and wakes.
+ if bootPass == nil {
+ t.Error("runAgent: no goroutine runs runWorkerBootPass")
+ } else if body := bootPass.Call.Fun.(*ast.FuncLit).Body; !receivesFrom(body, "started") || !callsNamed(body, "startupCtx.Done") {
+ t.Error("runAgent: the worker boot pass does not wait on started and startupCtx.Done")
+ }
+
+ // Nothing before the edge waits for startup. Outside func literals and
+ // go statements: no receive at all, no join, and a closure that waits
+ // on the latch is only called directly on a claim box, where Run has
+ // already started the activator before it returns.
+ var walk func(n ast.Node, claimBranch bool)
+ walk = func(n ast.Node, claimBranch bool) {
+ ast.Inspect(n, func(n ast.Node) bool {
+ if n == nil || n.Pos() >= edgeCall.Pos() {
+ return false
+ }
+ switch n := n.(type) {
+ case *ast.FuncLit, *ast.GoStmt:
+ return false
+ case *ast.IfStmt:
+ if n.Init != nil {
+ walk(n.Init, claimBranch)
+ }
+ walk(n.Cond, claimBranch)
+ walk(n.Body, claimBranch || str(n.Cond) == "claims.Writes.Enabled")
+ if n.Else != nil {
+ walk(n.Else, claimBranch)
+ }
+ return false
+ case *ast.UnaryExpr:
+ if n.Op == token.ARROW {
+ t.Errorf("runAgent waits on %s at %s, before the edge listens", str(n.X), fset.Position(n.Pos()))
+ }
+ case *ast.CallExpr:
+ s := str(n.Fun)
+ if s == "joinAgentStartup" {
+ t.Errorf("runAgent joins startup at %s, before the edge listens", fset.Position(n.Pos()))
+ }
+ if waiters[s] && !claimBranch {
+ t.Errorf("runAgent calls %s, which waits on the latch, at %s, before the edge listens", s, fset.Position(n.Pos()))
+ }
+ }
+ return true
+ })
+ }
+ walk(fn.Body, false)
+}
+
+// receivesFrom reports whether body receives from the channel named ch
+// (in a select case or a bare receive).
+func receivesFrom(body ast.Node, ch string) bool {
+ found := false
+ ast.Inspect(body, func(n ast.Node) bool {
+ if u, ok := n.(*ast.UnaryExpr); ok && u.Op == token.ARROW && types.ExprString(u.X) == ch {
+ found = true
+ }
+ return !found
+ })
+ return found
+}
+
+// callsNamed reports whether body calls a function whose expression is name.
+func callsNamed(body ast.Node, name string) bool {
+ found := false
+ ast.Inspect(body, func(n ast.Node) bool {
+ if c, ok := n.(*ast.CallExpr); ok && types.ExprString(c.Fun) == name {
+ found = true
+ }
+ return !found
+ })
+ return found
+}
+
+// callsWith reports whether body calls name with arg as its only argument.
+func callsWith(body ast.Node, name, arg string) bool {
+ found := false
+ ast.Inspect(body, func(n ast.Node) bool {
+ if c, ok := n.(*ast.CallExpr); ok && types.ExprString(c.Fun) == name && len(c.Args) == 1 && types.ExprString(c.Args[0]) == arg {
+ found = true
+ }
+ return !found
+ })
+ return found
+}
+
+func exprOrNil(e ast.Expr) string {
+ if e == nil {
+ return "unset"
+ }
+ return types.ExprString(e)
+}
+
// --- the box ------------------------------------------------------------------
type largeBox struct {
@@ -330,28 +622,50 @@ type largeBox struct {
client *http.Client
}
+// largeBoxSocketDir makes the box's run directory. It must be short: a
+// nested instance's socket is <dir>/instances/<instance id>/app.sock, the
+// activator binds a ".control" socket beside it, and the whole path must
+// fit a unix socket address (provision.MaxUnixPath). The default temp dir
+// is tried first, then a directory of the test's own directly under /tmp;
+// when neither is short enough the test is skipped, not failed, since
+// nothing about the code under test is wrong.
+func largeBoxSocketDir(t *testing.T) string {
+ t.Helper()
+ fits := func(dir string) bool {
+ return len(filepath.Join(dir, "instances", provision.InstanceID(largeBoxHost(0)), "app.sock.control")) <= provision.MaxUnixPath
+ }
+ var tried []string
+ for _, base := range []string{os.TempDir(), "/tmp"} {
+ dir, err := os.MkdirTemp(base, "lb")
+ if err != nil {
+ tried = append(tried, base+": "+err.Error())
+ continue
+ }
+ if fits(dir) {
+ t.Cleanup(func() { os.RemoveAll(dir) })
+ return dir
+ }
+ os.RemoveAll(dir)
+ tried = append(tried, dir+": too long")
+ }
+ t.Skipf("no directory short enough for a nested instance's control socket (%d bytes at most): %s", provision.MaxUnixPath, strings.Join(tried, "; "))
+ return ""
+}
+
// newLargeBox builds the box the agent restarts on: largeBoxRoutes
// sleeping instances on flat paths, largeBoxJournals of them with a layout
// move cut short at three different points, largeBoxHolds held down after
// a refused move (the reason, a second name for the database, persists),
-// a nested layout intent in the deployment store, a cached certificate for
-// every host the test asks for, and a cold instance control.
+// a scheduled delivery due on one instance, an always-on worker the boot
+// pass starts, a nested layout intent in the deployment store, a cached
+// certificate for every host the test asks for, and a cold instance
+// control.
func newLargeBox(t *testing.T) *largeBox {
t.Helper()
- b := &largeBox{dataDir: t.TempDir(), storeDir: t.TempDir(), acmeDir: t.TempDir()}
- // Short on purpose, as in the layout package's own tests: a nested
- // instance's socket, plus its ".control" sibling, must fit a unix
- // socket address.
- sd, err := os.MkdirTemp("", "lb")
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { os.RemoveAll(sd) })
- b.socketDir = sd
- if n := len(filepath.Join(sd, "instances", provision.InstanceID(largeBoxHost(0)), "app.sock.control")); n > provision.MaxUnixPath {
- t.Fatalf("socket dir %s leaves a %d-byte control socket; run with a short TMPDIR", sd, n)
- }
+ sd := largeBoxSocketDir(t)
+ b := &largeBox{dataDir: t.TempDir(), storeDir: t.TempDir(), acmeDir: t.TempDir(), socketDir: sd}
+ var err error
b.reg, err = registry.Open(filepath.Join(b.dataDir, "registry.db"))
if err != nil {
t.Fatal(err)
@@ -368,6 +682,18 @@ func newLargeBox(t *testing.T) *largeBox {
t.Fatal(err)
}
}
+ if err := b.reg.Upsert(registry.Route{
+ Host: largeBoxWorker, Kind: "sidecar", Account: "acct", App: "shop", Status: "ready", Args: `["serve"]`,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ due := time.Now().Unix() - 5
+ if err := b.reg.UpsertTimer(registry.Timer{
+ Host: largeBoxHost(largeBoxDueHost), Name: "tick", Kind: schedule.KindSchedule, Spec: "every 6h0m0s",
+ Path: largeBoxDuePath, NextAt: due, DueAt: due,
+ }); err != nil {
+ t.Fatal(err)
+ }
for i := 0; i < largeBoxJournals; i++ {
crash := "after-journal"
switch {
@@ -383,14 +709,13 @@ func newLargeBox(t *testing.T) *largeBox {
}
appendIntent(t, release.NewDirStore(b.storeDir), release.LayoutModeNested, "")
- served := []string{largeBoxHost(largeBoxWakeHost)}
+ served := []string{largeBoxHost(largeBoxWakeHost), largeBoxHost(largeBoxSidecarHost)}
for i := 0; i < largeBoxJournals+largeBoxHolds; i++ {
served = append(served, largeBoxHost(i))
}
pool := writeLargeBoxCerts(t, b.acmeDir, served)
b.control = startLargeBoxControl(t, filepath.Join(sd, "control.sock"), hosts)
- b.httpAddr, b.httpsAddr = freeLoopbackAddr(t), freeLoopbackAddr(t)
b.client = &http.Client{
Timeout: largeBoxLatchBound + 15*time.Second,
Transport: &http.Transport{
@@ -542,21 +867,31 @@ type largeBoxAnswer struct {
body string
header http.Header
err error
+ at time.Time // when the answer was read
}
-func (b *largeBox) get(host string) largeBoxAnswer {
- resp, err := b.client.Get("https://" + host + "/")
+// get asks the edge for host's root over TLS. sidecar marks the request
+// as the host's sidecar's (activator.SidecarHeader).
+func (b *largeBox) get(host string, sidecar bool) largeBoxAnswer {
+ req, err := http.NewRequest(http.MethodGet, "https://"+host+"/", nil)
if err != nil {
- return largeBoxAnswer{err: err}
+ return largeBoxAnswer{err: err, at: time.Now()}
+ }
+ if sidecar {
+ req.Header.Set(activator.SidecarHeader, "1")
+ }
+ resp, err := b.client.Do(req)
+ if err != nil {
+ return largeBoxAnswer{err: err, at: time.Now()}
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
- return largeBoxAnswer{status: resp.StatusCode, body: string(body), header: resp.Header, err: err}
+ return largeBoxAnswer{status: resp.StatusCode, body: string(body), header: resp.Header, err: err, at: time.Now()}
}
-func (b *largeBox) fire(host string) chan largeBoxAnswer {
+func (b *largeBox) fire(host string, sidecar bool) chan largeBoxAnswer {
ch := make(chan largeBoxAnswer, 1)
- go func() { ch <- b.get(host) }()
+ go func() { ch <- b.get(host, sidecar) }()
return ch
}
@@ -686,6 +1021,7 @@ func (c *largeBoxControl) systemctl(ctx context.Context, args ...string) (string
type largeBoxAgent struct {
cmd *exec.Cmd
began time.Time
+ listen time.Duration // process start to the gate probe's first answer
eventsPath string
log string
exited chan struct{} // closed once the process has been waited for
@@ -693,7 +1029,34 @@ type largeBoxAgent struct {
b *largeBox
}
+// startAgent starts the agent and returns it once the gate's probe is
+// answered and its TLS listener accepts. The edge's -http and -https take
+// an address, and :0 would bind, but the edge never says which port it
+// got, so the test picks free ports and hands them over. A port taken in
+// between makes the agent's listener fail and the agent exit, which is
+// told apart from any other failure by its log, and the agent is started
+// again on fresh ports.
func (b *largeBox) startAgent(t *testing.T) *largeBoxAgent {
+ t.Helper()
+ for attempt := 1; ; attempt++ {
+ b.httpAddr, b.httpsAddr = freeLoopbackAddr(t), freeLoopbackAddr(t)
+ a := b.launchAgent(t)
+ err := a.awaitListening()
+ if err == nil {
+ err = a.awaitTLS()
+ }
+ if err == nil {
+ return a
+ }
+ if a.collided() && attempt < largeBoxBindAttempts {
+ t.Logf("attempt %d: a port was taken before the agent bound it; starting again on fresh ports", attempt)
+ continue
+ }
+ t.Fatal(err)
+ }
+}
+
+func (b *largeBox) launchAgent(t *testing.T) *largeBoxAgent {
t.Helper()
cfgDir := t.TempDir()
cfg := largeBoxConfig{
@@ -718,6 +1081,7 @@ func (b *largeBox) startAgent(t *testing.T) *largeBoxAgent {
if err != nil {
t.Fatal(err)
}
+ t.Cleanup(func() { logFile.Close() })
a.cmd = exec.Command(exe, "-test.run=^TestStartupLargeBoxEndToEnd$", "-test.count=1")
// A clean environment: nothing of the developer's or the runner's
// (a deployment bucket, AWS credentials, CARLOS_*) reaches the agent.
@@ -729,7 +1093,6 @@ func (b *largeBox) startAgent(t *testing.T) *largeBoxAgent {
}
go func() {
a.exitErr = a.cmd.Wait()
- logFile.Close()
close(a.exited)
}()
t.Cleanup(func() {
@@ -751,13 +1114,12 @@ func (b *largeBox) startAgent(t *testing.T) *largeBoxAgent {
return a
}
-// waitListening probes as the update gate does (systemapply.go
+// awaitListening probes as the update gate does (systemapply.go
// productionSysOps.probeEdge): GET / on the edge's plain-HTTP listener,
// three-second client timeout, no redirect followed, and any HTTP answer
-// passes. It returns how long after the process started the first answer
+// passes. It records how long after the process started the first answer
// came, and fails past largeBoxListenBound.
-func (a *largeBoxAgent) waitListening(t *testing.T) time.Duration {
- t.Helper()
+func (a *largeBoxAgent) awaitListening() error {
probe := &http.Client{
Timeout: 3 * time.Second,
Transport: &http.Transport{Proxy: nil, DisableKeepAlives: true},
@@ -768,24 +1130,64 @@ func (a *largeBoxAgent) waitListening(t *testing.T) time.Duration {
resp, err := probe.Get("http://" + a.b.httpAddr + "/")
if err == nil {
resp.Body.Close()
- took := time.Since(a.began)
- if took > largeBoxListenBound {
- t.Fatalf("gate probe answered %s after the agent started, over %s", took, largeBoxListenBound)
+ a.listen = time.Since(a.began)
+ if a.listen > largeBoxListenBound {
+ return fmt.Errorf("gate probe answered %s after the agent started, over %s", a.listen, largeBoxListenBound)
}
- return took
+ return nil
}
select {
case <-a.exited:
- t.Fatalf("agent exited before listening: %v", a.exitErr)
+ return fmt.Errorf("agent exited before listening: %v", a.exitErr)
default:
}
if time.Now().After(deadline) {
- t.Fatalf("gate probe unanswered %s after the agent started: %v", time.Since(a.began), err)
+ return fmt.Errorf("gate probe unanswered %s after the agent started: %v", time.Since(a.began), err)
}
time.Sleep(20 * time.Millisecond)
}
}
+// awaitTLS waits, boundedly, for the edge's TLS listener: the edge binds
+// it in its own goroutine, beside the plain-HTTP one the gate probes, so
+// it can come up a moment later. Not part of the gate's measurement.
+func (a *largeBoxAgent) awaitTLS() error {
+ deadline := time.Now().Add(largeBoxListenBound)
+ for {
+ c, err := net.DialTimeout("tcp", a.b.httpsAddr, time.Second)
+ if err == nil {
+ c.Close()
+ select {
+ case <-a.exited:
+ return fmt.Errorf("agent exited after binding: %v", a.exitErr)
+ default:
+ return nil
+ }
+ }
+ select {
+ case <-a.exited:
+ return fmt.Errorf("agent exited before its TLS listener came up: %v", a.exitErr)
+ default:
+ }
+ if time.Now().After(deadline) {
+ return fmt.Errorf("TLS listener not up %s after the gate probe: %v", largeBoxListenBound, err)
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+}
+
+// collided reports whether the agent exited because a port it was handed
+// had been taken.
+func (a *largeBoxAgent) collided() bool {
+ select {
+ case <-a.exited:
+ case <-time.After(time.Second):
+ return false
+ }
+ out, _ := os.ReadFile(a.log)
+ return strings.Contains(string(out), "address already in use")
+}
+
func (a *largeBoxAgent) signal(t *testing.T, sig os.Signal) {
t.Helper()
if err := a.cmd.Process.Signal(sig); err != nil {
@@ -805,6 +1207,27 @@ func (a *largeBoxAgent) waitExit(t *testing.T, bound time.Duration) {
}
}
+// assertNeverStarted: startup never finished, so the activator never
+// started and nothing reached the backend or ran.
+func (a *largeBoxAgent) assertNeverStarted(t *testing.T) {
+ t.Helper()
+ for _, kind := range []string{"ready", "activator-start", "acquire", "start", "worker-start", "served"} {
+ if evs := a.events(kind); len(evs) != 0 {
+ t.Errorf("%s with startup unfinished: %v", kind, evs)
+ }
+ }
+}
+
+// assertAfter waits briefly for the event kind with args and fails unless
+// it came after ref.
+func (a *largeBoxAgent) assertAfter(t *testing.T, ref largeBoxEvent, kind string, args ...string) {
+ t.Helper()
+ e := a.waitEvent(t, 10*time.Second, kind, args...)
+ if e.at.Before(ref.at) {
+ t.Errorf("%s %v came %s before %s", kind, args, ref.at.Sub(e.at), ref.kind)
+ }
+}
+
// assertCleanShutdown reads the agent's own account of its shutdown: the
// startup work joined within runAgent's bound, and no goroutine of the
// startup sequence, the activator or the fake instances left after it.
@@ -956,7 +1379,9 @@ func runLargeBoxAgent(cfgPath string) int {
control := instancectl.NewClient(cfg.Control)
nestedOK := nestedGate(func() error { return instancefs.SelfCheck(dd) }, instancefs.MountNoSymfollow, control.Ready)
started := make(chan struct{})
- svc := activator.New(activator.Config{Bucket: "unused", Idle: time.Hour}, &largeBoxBackend{ev: ev, started: started}, reg)
+ backend := &largeBoxBackend{ev: ev, started: started}
+ svc := activator.New(activator.Config{Bucket: "unused", Idle: time.Hour}, backend, reg)
+ backend.svc = svc
svc.Routes = reg
svc.Startup = started
openStore := func(context.Context) (release.Store, error) { return release.NewDirStore(cfg.StoreDir), nil }
@@ -989,7 +1414,20 @@ func runLargeBoxAgent(cfgPath string) int {
Keys: largeBoxKeys{ev},
Slot: svc.PrepareScheduledToken,
Publish: armedLayoutPass.Store,
- Start: svc.Start,
+ // The checkpoint: when Start runs, every hold marker's host must
+ // already be fenced again and every journal complete.
+ Start: func() {
+ held, _ := layout.HoldMarkers(dd)
+ fenced := 0
+ for _, h := range held {
+ if svc.FenceGeneration(h) != 0 {
+ fenced++
+ }
+ }
+ journals, _ := layout.JournalHosts(dd)
+ ev.log("activator-start", "held="+strconv.Itoa(len(held)), "fenced="+strconv.Itoa(fenced), "journals="+strconv.Itoa(len(journals)))
+ svc.Start()
+ },
}.Run(startupCtx)
go func() {
<-startupDone
@@ -1006,9 +1444,32 @@ func runLargeBoxAgent(cfgPath string) int {
if err != nil {
return
}
- runWorkerBootPass(routes, svc.WakeWorker)
+ runWorkerBootPass(routes, func(rt registry.Route) error {
+ err := svc.WakeWorker(rt)
+ ev.log("worker-boot", rt.Host, err == nil)
+ return err
+ })
}()
+ // Admissions from inside the agent that do not pass runAgent's own
+ // gates, so they meet the activator's latch on their own: a worker
+ // wake as an operator command or the sidecar-wake leg makes it, and
+ // one scheduler tick through the real runner and deliverer with a
+ // delivery already due.
+ if rt, err := reg.Get(largeBoxWorker); err == nil && rt != nil {
+ go func() {
+ ev.log("worker-direct-begin", rt.Host)
+ err := svc.WakeWorker(*rt)
+ ev.log("worker-direct", rt.Host, err == nil)
+ }()
+ }
+ runner := &schedule.Runner{
+ Reg: reg, Deliver: &activatorDeliverer{svc: svc}, Token: scheduleToken(dd, svc, reg),
+ Now: time.Now, Timeout: largeBoxLatchBound + 15*time.Second, Box: "largebox", Build: "test",
+ }
+ runner.Tick(agentCtx) // delivers in the background, and returns
+ ev.log("tick-done")
+
edge.RunWithActivatorReady([]string{"-registry", regPath, "-acme-cache", cfg.AcmeDir, "-http", cfg.HTTP, "-https", cfg.HTTPS},
largeBoxActivator{Service: svc, ev: ev}, edge.NewCache(edge.DefaultCacheMaxEntries, edge.DefaultCacheMaxBodyBytes),
func(e *edge.Edge) {
@@ -1046,11 +1507,11 @@ func runLargeBoxAgent(cfgPath string) int {
// largeBoxStartupGoroutines lists every goroutine but this one that runs,
// or was started by, the startup sequence, the layout pass, the control
-// client, the activator, or this agent's own fakes, and every server
-// connection still open (a request held at the latch is answered only
-// once the activator shuts down, after the edge has stopped waiting for
-// it). The edge's process-lifetime loops (its registry watch, its SIGHUP
-// reader) are not startup's and are left alone.
+// client, the activator, the scheduler, or this agent's own fakes, and
+// every server connection still open (a request held at the latch is
+// answered only once the activator shuts down, after the edge has stopped
+// waiting for it). The edge's process-lifetime loops (its registry watch,
+// its SIGHUP reader) are not startup's and are left alone.
func largeBoxStartupGoroutines() []string {
buf := make([]byte, 8<<20)
n := runtime.Stack(buf, true)
@@ -1058,8 +1519,8 @@ func largeBoxStartupGoroutines() []string {
markers := []string{
"host.activatorStart", "host.startActivator", "host.backfillInstanceKeys", "host.keyBackfillInSlot",
"host.recoverLayoutAtStart", "host.startupRecoveryIntent", "host.runWorkerBootPass",
- "host.runLargeBoxAgent", "host.(*largeBoxBackend)", "host.largeBoxActivator",
- "layout.(*Pass)", "instancectl.(*Client)", "activator.(*Service)",
+ "host.runLargeBoxAgent", "host.(*largeBoxBackend)", "host.largeBoxActivator", "host.(*activatorDeliverer)",
+ "layout.(*Pass)", "instancectl.(*Client)", "activator.(*Service)", "schedule.(*Runner)",
"net/http.(*conn).serve",
}
var left []string
@@ -1090,11 +1551,13 @@ func (a largeBoxActivator) ServeHTTP(w http.ResponseWriter, r *http.Request, rt
// largeBoxBackend is activator.Backend with no S3 and no processes: a wake
// serves an app on the row's socket that names the host and the socket it
-// was started at. It records every acquire and start, and any acquire the
-// startup latch should have prevented.
+// was started at, and a worker reports ready at once, as a child does
+// over the agent socket. It records every acquire, start and request
+// served, and any acquire the startup latch should have prevented.
type largeBoxBackend struct {
ev *largeBoxEvents
started <-chan struct{}
+ svc *activator.Service
}
func (b *largeBoxBackend) Acquire(_ context.Context, rt registry.Route) (activator.Lease, error) {
@@ -1120,7 +1583,11 @@ func (b *largeBoxBackend) Start(_ context.Context, rt registry.Route) (activator
return nil, "", err
}
body := "host=" + rt.Host + " socket=" + rt.Socket
- p := &largeBoxProc{srv: &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { io.WriteString(w, body) })}, done: make(chan struct{})}
+ p := &largeBoxProc{done: make(chan struct{})}
+ p.srv = &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b.ev.log("served", rt.Host, r.URL.Path)
+ io.WriteString(w, body)
+ })}
go func() { defer close(p.done); _ = p.srv.Serve(ln) }()
return p, "", nil
}
@@ -1128,10 +1595,13 @@ func (b *largeBoxBackend) Start(_ context.Context, rt registry.Route) (activator
func (b *largeBoxBackend) NextDue(context.Context, registry.Route) (int64, error) { return 0, nil }
func (b *largeBoxBackend) HasSidecar(registry.Route) bool { return false }
func (b *largeBoxBackend) StartSidecar(context.Context, registry.Route) (activator.Proc, error) {
- return nil, errors.New("largeBoxBackend: no sidecars on this box")
+ return nil, errors.New("largeBoxBackend: no co-spawned sidecars on this box")
}
-func (b *largeBoxBackend) StartWorker(context.Context, registry.Route, map[string]string) (activator.Proc, string, error) {
- return nil, "", errors.New("largeBoxBackend: no workers on this box")
+
+func (b *largeBoxBackend) StartWorker(_ context.Context, rt registry.Route, _ map[string]string) (activator.Proc, string, error) {
+ b.ev.log("worker-start", rt.Host)
+ go b.svc.NoteWorkerReady(rt.Host, "")
+ return &largeBoxWorkerProc{}, "", nil
}
type largeBoxProc struct {
@@ -1149,6 +1619,11 @@ func (p *largeBoxProc) Stop(context.Context) error {
func (p *largeBoxProc) Exited() bool { return p.exited.Load() }
+type largeBoxWorkerProc struct{ exited atomic.Bool }
+
+func (p *largeBoxWorkerProc) Stop(context.Context) error { p.exited.Store(true); return nil }
+func (p *largeBoxWorkerProc) Exited() bool { return p.exited.Load() }
+
// largeBoxKeys is the exec backend's key writer as the key pass sees it:
// every row it is handed already has its key.
type largeBoxKeys struct{ ev *largeBoxEvents }