package calls import ( "amadan.net/oficina/estilo.git/chrome" "amadan.net/oficina/estilo.git/sky" "amadan.net/oficina/estilo.git/theme" "context" "crypto/rand" "encoding/hex" "fmt" "log/slog" "net/http" "sync/atomic" "time" "github.com/go-chi/chi/v5" gormlogger "gorm.io/gorm/logger" rauth "amadan.net/rastrillo/rastrillo/auth" "amadan.net/rastrillo/rastrillo/csrf" "amadan.net/rastrillo/rastrillo/db" "amadan.net/rastrillo/rastrillo/migrate" "amadan.net/oficina/calls/internal/calls/apns" "amadan.net/oficina/calls/internal/calls/hub" "amadan.net/oficina/calls/internal/calls/relay" "amadan.net/oficina/calls/internal/calls/seal" ) // app holds what every handler needs. type app struct { // signinMail is the queue sign-in links go through (signin_mail.go). signinMail *mailQueue db *db.DB cfg resolved sealer *seal.Sealer store *store logger *slog.Logger auth *rauth.Auth home *homeIntegration nativeAuth *nativeAuthorization linkBudget *budget outsiderBudget *budget // overBudget counts outsider sign-in attempts the budgets refused, // for sweepHourly to report: never logged on the response path. overBudget atomic.Int64 hub *hub.Hub // push is the APNs sender, or nil where no key is configured — which // is every test and every deployment that has not been given one. // Nil is not a degraded mode: knocks still reach every member who is // in the room, exactly as before. It is only the member who is not // there who goes untold. push knockPusher // hubSalt keys the session cookie into the hub's session id, so the // hub holds no session material it did not have to. hubSalt string // signalLimiter is POST /m/{sqid}/signal's per-peer token bucket // (spec §4.4): thirty a second, burst sixty. signalLimiter *rateLimiter // apiLimiter is the drive surface's per-agent token bucket (spec // §8): sixty requests a minute, burst sixty, keyed "agent:". apiLimiter *rateLimiter // qualityLimiter and problemLimiter are the report endpoints' (spec // §15.26), keyed on the reporting participation's tag: a quality // report every ten seconds, burst six; a problem report a minute, // burst three. qualityLimiter *rateLimiter problemLimiter *rateLimiter // reportLog is where the reports go: Config.ReportLogger. reportLog *slog.Logger // tickPass is the housekeeping pass tickLoop runs, nil in production // and everywhere but the one test that records what deadline the // scheduling path hands it (see tickLoop). tickPass func(ctx context.Context, within time.Duration) // done closes when the context App was given is cancelled — a // SIGTERM, in the deployed shape. Live streams watch it and return, // because http.Server.Shutdown waits for handlers without // cancelling their contexts: one open stream would otherwise hold // the whole shutdown deadline. done <-chan struct{} // The stream's two timings, held as atomics because a test moves // them (SetStreamTimings) while stream handlers are reading them. heartbeat atomic.Int64 ttl atomic.Int64 // iceLifeTest, when set (tests, development only), is the life every // /ice answer is minted with (SetIceLife). iceLifeTest atomic.Int64 // allow is the team, held as an atomic for the same reason: a test // drops a member from it (SetAllowlist) while the tick goroutine is // re-deriving standing. Nil means "as resolved at boot"; nothing in // production ever stores into it. Read it with allowNow(). allow atomic.Pointer[allowlist] // relay is the parsed CARLOS_RELAY contract, held as an atomic so a // test can hand the app a relay after boot (TestHandle.SetRelay) // while a request is reading it. build stores rc.relay once; // production never stores again. Read it with relayRegions(). relay atomic.Value } // relayRegions is the parsed contract, swapped atomically so a test can // hand the app a relay after boot (TestHandle.SetRelay). Production // sets it once in build. func (a *app) relayRegions() []relay.Region { v, _ := a.relay.Load().([]relay.Region) return v } func (a *app) relayConfigured() bool { return len(a.relayRegions()) > 0 } func (a *app) heartbeatEvery() time.Duration { return atLeast(a.heartbeat.Load()) } func (a *app) streamLife() time.Duration { return atLeast(a.ttl.Load()) } // atLeast keeps a timing above the floor: time.NewTicker panics on a // zero or negative duration, and that would be inside a request. func atLeast(ns int64) time.Duration { if d := time.Duration(ns); d > minStreamTiming { return d } return minStreamTiming } // TestHandle is the test-only control surface NewForTest hands back: // the app's clock, and the sweep that would otherwise only run on a // timer. Tests only; nothing in production holds one. type TestHandle struct { a *app stop context.CancelFunc } // ShutdownForTest cancels the context the app was built on, which is // what a SIGTERM does to the deployed process: live streams see it and // return. It does not stop the handler serving requests. func (h *TestHandle) ShutdownForTest() { h.stop() } // SetStreamTimings shortens the SSE heartbeat and TTL so a test can // watch a stream expire without waiting five minutes. Set it before any // stream opens: a stream reads both once, when it starts. func (h *TestHandle) SetStreamTimings(heartbeat, ttl time.Duration) { h.a.heartbeat.Store(int64(heartbeat)) h.a.ttl.Store(int64(ttl)) } // SetIceLife shortens the relay credentials /ice mints, for every request // (the X-Calls-Test-Ice-TTL header does it for one): a gate that needs a // renewal mid-call cannot add a header to the native core's fetch. Zero // puts the production life back. Tests only; development only. func (h *TestHandle) SetIceLife(d time.Duration) { h.a.iceLifeTest.Store(int64(d)) } // SetAllowlist replaces the team, so a test can drop a member and watch // the next tick apply spec §2.4's transition to everyone that member // holds a participation for. The addresses are normalized exactly as // resolveAllowlist normalizes $CALLS_ALLOWED_ADDRESSES, and the list is // always non-nil: SetAllowlist() with no addresses means "nobody is a // member", never development's "everybody is". func (h *TestHandle) SetAllowlist(addrs ...string) { list := allowlist{addrs: map[string]struct{}{}} for _, addr := range addrs { if addr = normalizeAddress(addr); addr != "" { list.addrs[addr] = struct{}{} } } h.a.allow.Store(&list) } // SetRelay hands the app a CARLOS_RELAY value after boot. Tests only. func (h *TestHandle) SetRelay(raw string) error { regions, err := relay.Parse(raw) if err != nil { return err } h.a.relay.Store(regions) return nil } // TickForTest runs one pass of the hub housekeeping App runs every // fifteen seconds: unjoined and lapsed participations dropped, // tombstones expired, every joined kind re-derived from standing. func (h *TestHandle) TickForTest() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() h.a.hub.Tick(ctx) } // SetClock replaces the store's clock, so a test can expire grants, // pending rows and magic links without sleeping. func (h *TestHandle) SetClock(now func() time.Time) { h.a.store.clk.set(now) } // SweepForTest runs one bounded pass of what the hourly ticker runs, // using now for both store.sweep and auth.Sweep — Internal 5's one // clock for the whole pass, given explicitly rather than read back off // the store, so a test does not have to keep the two in step by hand. func (h *TestHandle) SweepForTest(now time.Time) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() h.a.sweepOnce(ctx, now) } // NextDue answers what Runtime.NextDue would answer right now — the // platform wake-up time App wires to Options.NextDue. Exposed so a // black-box callstest test can check it after driving a real request, // without reaching into the store directly. func (h *TestHandle) NextDue() time.Time { return h.a.nextDue() } // SignalLimiterSize answers how many keys the signal endpoint's rate // limiter currently holds — a black-box test's only window into // whether a request minted a bucket at all (review Task 5 item 1: a // room id rotation, or anything else refused before the limiter, must // leave this unchanged). func (h *TestHandle) SignalLimiterSize() int { return h.a.signalLimiter.size() } // gormWriter is GORM's logger.Writer over the app's slog logger, so // GORM's warnings land in one log stream rather than a second stdout // format. rastrillo/db has the same adapter, unexported. type gormWriter struct{ log *slog.Logger } func (w gormWriter) Printf(format string, args ...any) { w.log.Warn(fmt.Sprintf(format, args...)) } // sweepEvery is how often expired rows are cleared. Nothing depends on // it for correctness — every read filters on expiry — so the interval // is about not keeping records past their life, not about safety. const sweepEvery = time.Hour // sweepOnce clears Calls' expired pending rows and rastrillo's expired // auth links and sessions (review C1-7), one bounded call to // store.sweep — not looped, so a caller with a deadline (the boot sweep, // SweepForTest) gets one page of work done and returns. An unclicked // magic link holds a plaintext address in auth_links, and rastrillo only // deletes it when the token is presented or when Sweep is called; nobody // was calling Sweep. // // now drives both halves: one clock for the whole pass (Internal 5), so // a test that advances Calls' clock expires rastrillo's auth_links rows // too. In production the caller passes the store's own clock, so it is // simply time.Now. func (a *app) sweepOnce(ctx context.Context, now time.Time) { if n, err := a.store.sweep(ctx, now); err != nil { a.logger.Error("calls: sweep pending joins", "err", err) } else if n > 0 { a.logger.Info("calls: swept pending joins", "deleted", n) } if err := a.auth.Sweep(now); err != nil { a.logger.Error("calls: sweep auth links and sessions", "err", err) } } // sweepHourly is what the ticker runs: store.sweep is bounded to // sweepLimit rows a call (review C2-3), so a backlog bigger than that // needs more than one call to actually clear it — the loop the boot // sweep and SweepForTest deliberately do not do, because a deadline- // bound or test-driven caller wants one page of work, not "until done". func (a *app) sweepHourly(ctx context.Context) { if n := a.overBudget.Swap(0); n > 0 { a.logger.Warn("calls: outsider sign-in attempts over budget", "count", n) } now := a.store.now() for { n, err := a.store.sweep(ctx, now) if err != nil { a.logger.Error("calls: sweep pending joins", "err", err) break } if n > 0 { a.logger.Info("calls: swept pending joins", "deleted", n) } if n < sweepLimit { break } } if err := a.auth.Sweep(now); err != nil { a.logger.Error("calls: sweep auth links and sessions", "err", err) } } // sweepForever is started by App and not by NewForTest: a test builds an // app per case, and an hourly ticker per case is a goroutine per case // that outlives it holding the store and the database open. It exits on // ctx.Done, so App's caller can stop it by cancelling the context it // passed in. func (a *app) sweepForever(ctx context.Context) { t := time.NewTicker(sweepEvery) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: a.sweepHourly(ctx) } } } // tickEvery is the hub's housekeeping interval (spec §2.4): the quiet // case, for participations nothing else is touching. const tickEvery = 15 * time.Second // tickForever is started by App and not by NewForTest, for the reason // sweepForever is: a ticker per test app is a goroutine per test that // outlives it. Tests drive it with TestHandle.TickForTest. func (a *app) tickForever(ctx context.Context) { a.tickLoop(ctx, tickEvery) } // tickLoop is the scheduling path, with the interval as a parameter so // the one property that matters can be gated in milliseconds rather than // in fifteen-second slots: the deadline each pass is given is the // interval between passes, so a pass can never overrun the slot the next // one needs. `every` goes to the ticker and to the pass, in one place, // and there is no second constant to drift from the first. // // tickPass is nil everywhere but in that test, which substitutes a // recorder for it; the alternative — asserting a constant equals itself // — left the relationship unprotected (Codex S2-14). func (a *app) tickLoop(ctx context.Context, every time.Duration) { pass := a.tickPass if pass == nil { pass = a.tickOnce } t := time.NewTicker(every) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: pass(ctx, every) } } } // tickOnce is one housekeeping pass under a deadline of its own (review // W-12). The read phase does one standing lookup per joined // participation, serially, with the hub lock down; given only the // process context, a driver that stops answering would stall §2.4's // quiet case for as long as it stayed stuck, and a removed member would // then be dropped only by the next thing they did. A pass that runs // past the interval has already lost its slot, so `within` is that // interval: the reads that did answer are applied, the ones that did // not wait for the next pass, which is what Tick already does with a // failed lookup. func (a *app) tickOnce(ctx context.Context, within time.Duration) { ctx, cancel := context.WithTimeout(ctx, within) defer cancel() a.hub.Tick(ctx) } // build wires the whole app: refusals, schema, router, static files. It // returns the *app alongside the mux so tests can reach into it (see // NewForTest); production code uses App, which discards the *app. // // ctx bounds the sweep at boot, and its cancellation is what tells live // streams the process is stopping (a.done). The hourly ticker is not // started here: App starts it, NewForTest does not, so a test suite does // not leak a goroutine per app it builds. func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, error) { rc, err := resolve(cfg) if err != nil { return nil, nil, err } sealer, err := seal.New(rc.dek) if err != nil { return nil, nil, err } // rastrillo/db configures GORM's logger without ParameterizedQueries, // so a slow or failing query is logged with its values interpolated // into the SQL. ensurePerson looks a person up by address, so that // one log line would carry an outsider's plaintext address (review // C1-2). Same thresholds as db.Open, placeholders instead of values. d.G.Logger = gormlogger.New(gormWriter{rc.Logger}, gormlogger.Config{ SlowThreshold: 200 * time.Millisecond, LogLevel: gormlogger.Warn, IgnoreRecordNotFoundError: true, ParameterizedQueries: true, }) migrated, err := migrate.Apply(context.Background(), d, BootSchema) if err != nil { return nil, nil, err } rc.Logger.Info("migrate", "applied", migrated.Applied, "skipped", migrated.Skipped, "adopted", migrated.Adopted) st := &store{g: d.G, sealer: sealer, clk: newClock(time.Now), logger: rc.Logger} a := &app{ db: d, cfg: rc, sealer: sealer, store: st, logger: rc.Logger, linkBudget: newBudget(5, 15*time.Minute), outsiderBudget: newBudget(10, 15*time.Minute), } salt := make([]byte, 16) if _, err := rand.Read(salt); err != nil { return nil, nil, err } a.hubSalt = hex.EncodeToString(salt) a.done = ctx.Done() a.heartbeat.Store(int64(defaultHeartbeat)) a.ttl.Store(int64(defaultStreamTTL)) a.relay.Store(rc.relay) if len(rc.relay) > 0 { rc.Logger.Info("calls: relay configured", "regions", len(rc.relay)) } // The hub reads standing back through the app, and never under its // own lock: hubStanding is a database read. // The APNs sender, where a key was configured. Built before the hub // so that a.knocked has something to send with the first time it is // called, which on a busy instance can be immediately. if rc.apns != nil { client, err := apns.New(*rc.apns) if err != nil { return nil, nil, fmt.Errorf("calls: Apple push: %w", err) } a.push = client rc.Logger.Info("calls: Apple push configured", "sandbox", rc.APNsSandbox) } // The hub reads standing back through the app, and never under its // own lock: hubStanding is a database read. Knocked is the same // arrangement for the other direction. a.hub = hub.New(hub.Config{ Now: st.now, Logger: rc.Logger, StandingFor: a.hubStanding, Knocked: a.knocked, WaitingRoom: func(ctx context.Context, sqid string) (bool, error) { m, _, err := st.meetingBySqid(ctx, sqid) return m.WaitingRoom, err }, }) a.signalLimiter = newRateLimiter(30, 60, maxSignalBuckets) a.apiLimiter = newRateLimiter(apiRatePerSecond, apiBurst, maxAgentBuckets) a.qualityLimiter = newRateLimiterEvery(10*time.Second, 6, maxReportBuckets) a.problemLimiter = newRateLimiterEvery(time.Minute, 3, maxReportBuckets) a.reportLog = rc.ReportLogger if !rc.qualityReports { rc.Logger.Info("calls: quality reports off (CALLS_QUALITY_REPORTS=off)") } writer, err := d.G.DB() if err != nil { return nil, nil, err } // Sign-in links go out after the response, from a worker that asks // authorize first (signin_mail.go). It is NOT closed when ctx ends: // ctx ends at SIGTERM, while Server.Shutdown is still letting // accepted requests finish, and a request that reaches the queue // after that must still have its link sent (Astra on #25, round 2). // Whoever built the app closes it once no request can arrive: // Runtime.Close (main, after rastrillo.Serve returns) or the // TestHandle's stop. Only a failed build closes it here. a.signinMail = newMailQueue(a.cfg.mailer, a.authorize, mayMail, a.logger) if w, ok := cfg.Mailer.(queueAttacher); ok { w.AttachSigninQueue(a.signinMail.wait) } authPlugin, err := a.newAuthPlugin(writer) if err != nil { a.signinMail.close() return nil, nil, err } a.auth = authPlugin if err := a.wireHome(); err != nil { a.signinMail.close() return nil, nil, err } if err := a.wireNativeAuth(); err != nil { a.signinMail.close() return nil, nil, err } // Bounded, not open-ended: a boot sweep runs on the caller's ctx but // must still finish, so an app never fails to start (or hangs a // test) because of a stuck sweep (review C2-3). bootCtx, cancel := context.WithTimeout(ctx, 30*time.Second) a.sweepOnce(bootCtx, a.store.now()) cancel() r := chi.NewRouter() // Every panic inside the router is the 500 page with a reference and // the viewer's own chrome (errors.go); an unknown route is the 404 // page, not chi's plain text (shared chrome spec §3.3). r.Use(a.recoverToErrorPage) r.NotFound(a.notFound) r.MethodNotAllowed(a.methodNotAllowed(r)) // The drive surface (spec §8.2) is mounted at the router root, // outside csrf.Protect and outside the session middlewares: an // agent is not a browser and sends none of the headers // csrf.SameOrigin needs (a POST carrying none of them is refused), // while RequireSession would redirect its bearer to the sign-in // page. apiRoutes says why that is safe — this surface honours no // cookie, so there is no ambient credential for CSRF to protect. r.Group(a.apiRoutes) r.Post("/native/request", a.nativeRequest) r.Post("/native/exchange", a.nativeExchange) r.Group(func(r chi.Router) { if a.home == nil { r.Use(csrf.Protect(rc.Origin)) } else { r.Use(a.home.routes.Middleware) } r.Use(a.nativeResume) r.Get("/native/start", a.nativeStart) r.Get("/help/open-source", a.credits) // Appearance is inside the CSRF check and outside the session // group: a session that expired mid-page must not turn the click // into a sign-in loop (spec §8.3). r.Post("/appearance", chrome.HandleAppearance) r.Get(signinPath, a.signin) // The handlers rastrillo/auth expects an app to mount. Verify // answers GET with the confirm door (renderConfirm), which spends // nothing, and POST with the redemption, so it is mounted for // both methods in both modes, wrapped alike: without the POST the // door's Sign in button gets a 405 and nobody signs in. The path // is baked into every emailed link. A verified address authorize // refuses is answered by auth's Refused hook (refused), inside // Callback and Verify. if a.home == nil { r.Post(signinPath, a.newBegin(authPlugin.Begin)) r.Get("/auth/callback", authPlugin.Callback) r.Get("/auth/verify", authPlugin.Verify) r.Post("/auth/verify", authPlugin.Verify) r.Post("/signout", signedOutAfter(authPlugin.Signout)) r.Get("/signed-out", a.signedOut) } else { r.Post(signinPath, a.home.routes.LocalGuard(a.newBegin(authPlugin.Begin))) r.Get("/auth/callback", a.home.routes.LocalGuard(authPlugin.Callback)) r.Get("/auth/verify", a.home.routes.LocalGuard(authPlugin.Verify)) r.Post("/auth/verify", a.home.routes.LocalGuard(authPlugin.Verify)) r.Post("/signout", a.home.routes.Signout) r.Get("/signin/local", a.localSignin) r.Get("/signed-out", a.home.routes.SignedOut) r.Get("/auth/start", a.home.bridge.Start) r.Post("/auth/assertion", a.home.bridge.Callback) r.Get("/auth/finish", a.home.bridge.Finish) r.Get("/auth/local/complete", a.home.routes.LocalComplete) } r.Get("/j/{token}", a.followLink) r.Post("/j/{token}/guest", a.joinGuest) // A verified or guest session, with a persons row. Guest sessions are // restricted to participation and native authorization routes. r.Group(func(r chi.Router) { r.Use(a.requireParticipant) r.Get("/", a.index) r.Get("/native/complete", a.nativeComplete) r.Get("/native/authorize", a.nativeAuthorize) r.Post("/native/authorize", a.nativeApprove) r.Get("/continue", a.continueSignedIn) r.Get("/welcome", a.welcome) r.Get("/m/{sqid}/join", a.holding) r.Get("/m/{sqid}/app", a.appOffer) r.Get("/native/offer", a.offerPage) r.Get("/m/{sqid}", a.room) r.Get("/m/{sqid}/info", a.roomInfo) r.Get("/m/{sqid}/remove/{peer}", a.confirmRemove) r.Post("/m/{sqid}/remove/{peer}", a.remove) r.Get("/m/{sqid}/takeover", a.confirmTakeover) r.Post("/m/{sqid}/takeover", a.takeover) r.Group(func(r chi.Router) { r.Use(a.requireMember) if a.home != nil { r.Get("/account/home", a.home.routes.Account) r.Post("/account/home", a.home.routes.LinkStart) r.Get("/account/home/confirm", a.home.bridge.ConfirmPage) r.Post("/account/home/confirm", a.home.routes.ConfirmPost) } r.Post("/meetings/start", a.startCall) r.Post("/native/meetings/start", a.nativeStartMeeting) r.Post("/native/devices", a.registerDevice) r.Post("/meetings", a.createMeeting) r.Post("/m/{sqid}/share", a.shareMeeting) r.Get("/meetings/{sqid}/links/replace", a.confirmReplaceLink) r.Post("/meetings/{sqid}/links", a.replaceLink) r.Get("/meetings/{sqid}/end", a.confirmEnd) r.Post("/meetings/{sqid}/end", a.endMeeting) r.Get("/settings", a.settings) r.Post("/settings/defaults", a.saveDefaults) r.Get("/meetings/{sqid}/settings", a.meetingSettings) r.Post("/meetings/{sqid}/settings", a.saveSettings) r.Post("/settings/agents", a.createAgentHandler) r.Get("/settings/agents/{id}/revoke", a.confirmRevokeAgent) r.Post("/settings/agents/{id}/revoke", a.revokeAgentHandler) }) r.Get("/m/{sqid}/stream", a.stream) r.Post("/m/{sqid}/signal", a.signal) r.Get("/m/{sqid}/ice", a.ice) r.Post("/m/{sqid}/quality", a.quality) r.Post("/m/{sqid}/problem", a.problem) }) }) mux := http.NewServeMux() mux.HandleFunc("GET /static/credits.css", serveCreditsCSS) mux.Handle("GET /static/", assets.Handler()) // The sky behind the sign-in door: estilo's closed set of public // SVGs, sessionless, more specific than /static/ above. mux.Handle("GET "+sky.Prefix, sky.Handler()) // Onest, Calma's typeface, served by estilo under its versioned prefix. mux.Handle("GET "+theme.FontPrefix, theme.FontHandler()) mux.Handle("/", r) return mux, a, nil } // Runtime is what App hands main beyond the mux: the platform's // scheduled-wake function (review C2-2), the error page, and the // shutdown drain. It is a struct rather than a bare func so a later // addition does not change App's signature again. type Runtime struct { // NextDue answers rastrillo.Options.NextDue: the next moment this // app has work, or the zero time when nothing is scheduled. NextDue func() time.Time // ErrorPage answers rastrillo.Options.ErrorPage: the app's own error // page for a panic outside the router. ErrorPage func(w http.ResponseWriter, r *http.Request, status int, ref string) // Close stops the sign-in mail queue taking links and drains it, // within its bounded deadline, returning when that is done. The // queue is NOT closed when App's ctx ends: that happens at SIGTERM, // while Server.Shutdown still lets accepted requests finish, and // their links must go out. So main calls Close after rastrillo.Serve // returns (no request can arrive any more) and before the database // closes; SMTP runs after the response, so Shutdown does not wait // for it. Every caller of App must call Close, or the queue's worker // outlives the app. A second call joins the first. Close func() } // nextDue is Runtime.NextDue's implementation: the earliest of the next // nextExpiry (across pending_joins, auth_links and sessions) and one // hour from now — the hourly sweep's own interval, so the platform // never lets the app sleep past a sweep it would otherwise have run — // or the zero time when nothing at all is outstanding, meaning there is // nothing to wake early for. // // A read failure does NOT answer the zero time: zero means "nothing // scheduled" to the platform (serve.go's contract), so returning it on // a transient error would cancel the wake entirely rather than merely // delay it. An hour out is the same retry deadline the healthy path // already uses for "nothing due sooner than the next sweep," so a // flaky read gets one more try at the next poll instead of going quiet // (Codex round 3, C3-1). func (a *app) nextDue() time.Time { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() retry := a.store.now().Add(sweepEvery) exp, err := a.store.nextExpiry(ctx) if err != nil { a.logger.Error("calls: next due: read next expiry", "err", err) return retry } if exp.IsZero() { return time.Time{} } if retry.Before(exp) { return retry } return exp } // App wires the whole app: refusals, schema, router, static files. It // returns a *http.ServeMux because rastrillo.Options.Mux is typed that // way; the chi router mounts inside it, and a *Runtime carrying the // platform wake function main.go wires to Options.NextDue. It also // starts the hourly sweep and the hub tick on ctx, which run until ctx // is cancelled or the process exits. // // Cancel ctx on SIGTERM: live SSE streams end when it is cancelled, and // http.Server.Shutdown waits for handlers without cancelling their // contexts, so a stream that does not watch this holds the shutdown // deadline open. func App(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *Runtime, error) { mux, a, err := build(ctx, d, cfg) if err != nil { return nil, nil, err } go a.sweepForever(ctx) go a.tickForever(ctx) // ctx's end stops streams, the sweep and the tick; the sign-in mail // queue keeps taking links until Runtime.Close (see build). return mux, &Runtime{NextDue: a.nextDue, ErrorPage: a.ErrorPage, Close: a.signinMail.close}, nil } // NewForTest builds the app and returns the handler and its test // controls. No sweep ticker is started: see build. Tests only; // production uses App. func NewForTest(d *db.DB, cfg Config) (*http.ServeMux, *TestHandle, error) { ctx, cancel := context.WithCancel(context.Background()) mux, a, err := build(ctx, d, cfg) if err != nil { cancel() return nil, nil, err } // A test's stop (ShutdownForTest) ends the app outright, mail queue // included: no server is draining requests in front of it. go func() { <-ctx.Done(); a.signinMail.close() }() return mux, &TestHandle{a: a, stop: cancel}, nil }