Chapter 27 / 51
Volume 4 · Hot Reload Deep Dive

Triggering & querying: exposing reloads to the right people

XServer doesn't dictate "who reloads when" — it only provides Submit/Query. This chapter designs the trigger surface: management routes for ops, file watching for devs, and presenting the results.

Overview

Positioning: last chapter's model lands as a "product" here. Management routes are most deployments' choice (ch. 40 grows them into a plugin system); file watching gives you the IDE save-and-see dev experience.

27.1 Intro

Trigger design answers three questions: who may trigger (authority), at what granularity (host / server / all), and how to learn the outcome. Interestingly, XServer leaves all three to the application — because "the management plane belongs to the application layer" (ch. 1's trap). This chapter offers two proven answers.

27.2 The management-route pattern

The /admin/reload management route (full pattern)
static void Handle_Admin_Reload(XS_HttpReq* pReq, const RouteParamHTTP* a, uint32 n)
{
    xvalue* pOut = xrtValueObject();
    XS_ReloadId iId;

    /* 1. auth: the management plane must sit behind a trust boundary
     *    (fixed token here; plug any auth system in production) */
    if ( !CheckAdminToken(pReq) ) {
        (void)ReplyText(pReq, 403, "forbidden");
        goto Out;
    }

    /* 2. granularity: scope=host|server|all */
    if ( ScopeIsAll(pReq) )      iId = xsReloadAllSubmit();
    else if ( ScopeIsServer(pReq) ) iId = xsReloadServerSubmit(sName);
    else                        iId = xsReloadHostSubmit(pReq->host);

    /* 3. reply with acceptance at once (no waiting for the terminal) */
    if ( iId == 0 ) {
        (void)ReplyText(pReq, 503, "reload queue full");
        goto Out;
    }
    /* out: {"id":123} — results via /admin/reload/status?id=123 */
Out:
    xrtValueRelease(pOut);
}

What just happened — design decisions:

  • Acceptance separate from outcome: POST returns the ID at once; the outcome is a separate GET route — no request thread waits for a terminal (Submit never blocks; neither should waiting)
  • Three granularities: daily use takes host (smallest blast radius); structural changes take server; global config changes take all (batch-transaction semantics)
  • Auth is the application's first line: a management route is "a route that changes service behavior" — same authority, different risk than business routes

27.3 Auto-triggering: save-and-reload

The developer-experience trigger — watch the devfile directory and submit on change:

A file-watch trigger (pattern)
/* ServiceInit: start watching + periodic checks (xrt file watching / mtime polling both work) */
static void OnSrcChanged(void* pUserData)
{
    XS_HostInfo* pHost = (XS_HostInfo*)pUserData;
    /* debounce: a save storm submits only the last (latest-wins as backstop) */
    (void)xsReloadHostSubmit(pHost);
}
/* note: timers die with their generation — re-arm the watch in the new generation's Init */

Debouncing has two layers: the application merges rapid successive changes in a short window; and if it misses, latest-wins squeezes stale intents into SUPERSEDED — this trigger's worst case is a few extra compiles, never an intermediate state going live. Production usually leaves auto-triggering off: a reload is a controlled change, not a filesystem event.

27.4 Presenting results

/admin/reload/status (result query)
static void Handle_Admin_ReloadStatus(XS_HttpReq* pReq, const RouteParamHTTP* a, uint32 n)
{
    XS_ReloadResult t;
    xvalue* pOut = xrtValueObject();

    if ( !xsReloadQuery(ParseId(pReq), &t) ) {
        (void)ReplyText(pReq, 404, "no such id");
        return;
    }
    xrtValueObjectSetNew(pOut, XRT_STR_LITERAL("id"),
        xrtValueInt64((int64)t.Id));
    xrtValueObjectSetNew(pOut, XRT_STR_LITERAL("state"),
        xrtValueInt64((int64)t.State));
    xrtValueObjectSetNew(pOut, XRT_STR_LITERAL("revision"),
        xrtValueInt64((int64)t.Revision));
    xrtValueObjectSetNew(pOut, XRT_STR_LITERAL("message"),
        xrtValueString(xrtStrView(t.Message)));  /* the FAILED reason */
    (void)ReplyJSON(pReq, 200, pOut);
    xrtValueRelease(pOut);
}

The terminal arrives within tens of milliseconds to seconds (by script size); the frontend may poll or refresh — Query is idempotent and side-effect-free.

27.5 Contract

  • Auth: management routes require application-layer auth; they change service behavior — they are not ordinary business endpoints.
  • Granularity: host level by default; all only for global config changes (batch transaction).
  • Presentation: acceptance separate from outcome; queries idempotent.

Common mistakes

A management route running naked
Symptom
A publicly reachable /admin/reload — anyone can swap your service's code.
Cause
The trigger surface is the change surface. Minimum: a dedicated vhost (intranet-bound) + a token; better yet, ch. 44's trust-boundary design.
A file watch ignoring generations
Symptom
The auto-trigger goes dead after one hot reload.
Cause
The watch's timers/handles die with the old generation (ch. 30) — the new generation's ServiceInit must re-arm it. That's the other face of generation isolation's "free safety".

Exercises

BasicA three-tier management route

Implement scope=host/server/all submission plus the status query; walk the whole flow with curl.

Acceptance: each tier succeeds once; a broken script returns FAILED with the reason in status.
AdvancedToken auth

Add constant-time token checking to the management routes; rate-limit wrong tokens (ch. 22's demotion idea).

Acceptance: wrong tokens 403; brute force throttled to a negligible rate.
ChallengeA dev-mode trigger

Implement mtime-polling save-and-reload (debounce + per-generation re-arming); measure save-to-live end-to-end latency.

Acceptance: 10 rapid saves put only the final version live; end-to-end latency < 2s.

Summary

PointConclusion
Three trigger questionswho (auth), granularity (host/server/all), outcome (Query)
Management routesacceptance separate from outcome; auth is the application's first line
Auto-triggeringwatch + Submit; two-layer debounce (app merging + latest-wins)
Generation notetriggers die with their generation; re-arm in the new Init
xrt further reading