dispatcher: don't destroy fresh instances on the empty-container describe window; drain daemon runners before TTL #1

Merged
founder merged 1 commit from fix/stop-monitor-startup-window into main 2026-08-16 11:16:20 +00:00
Owner

Problem

Measured on the forge (journalctl -u forgejo-nsc-dispatcher, 24h to 2026-08-16 10:00Z): 125 of 176 runner launched events were followed by runner destroyed within 2–5 s, across every machine type, while Forgejo showed those runners registered and online.

Reproduced as root on the forge with nsc run --wait --output json …; nsc describe --output json <id>:

t after nsc run --wait returned nsc describe --output json
+0.03 s per_resource.<uid> has only namespace/name/uidno container key
+3.5 s container: [{status: running, ready: true, started_at: …}] (started_at ≈ 4 s after --wait returned)
+27 s (process exited) tombstone set, container status: stopped + terminated_at
+48 s describe fails: … failed to start or was destroyed (FailedPrecondition)

instanceStopped() treated the empty container list as "all containers stopped" (continue → fall through → return true), so the first check in waitForInstanceStop destroyed the instance immediately.

Fix

Stop monitor (internal/nsc/dispatcher.go)

  • describeInstancePhase() classifies describe output as unknown / starting / running / stopped. Only a tombstone or an explicitly stopped/terminated container is stopped; a missing/empty container list is starting.
  • waitForInstanceStop() skips the immediate check (10 s initial delay), keeps polling through starting, tracks whether a container was ever observed, and only trusts a not found/destroyed describe error once a container was seen or a 2 m startup grace has passed. Transient describe errors are now retried until the TTL+5m deadline instead of ending the wait (which made the caller destroy a possibly mid-job runner). First check logs the raw describe JSON (runner first stop check); phase transitions are logged.
  • Intervals live in stopMonitorConfig so tests can shrink them.

TTL drain (related hazard from the same investigation): daemon-mode runners accepted jobs right before the nsc run --duration TTL expired (job 97045 in run 17183 was killed when nscloud-176cbcc386d5, launched 08:47 with a 90 m TTL, hit 10:17).

  • LaunchRunner passes FORGEJO_RUNNER_TTL_DEADLINE_EPOCH (computed before nsc run, so never later than the real expiry) and FORGEJO_RUNNER_DRAIN_SECONDS (TTL/3 clamped to 5m..30m, ≤ TTL/2 → 30 m for 90 m TTL, 5 m for 15 m; overridable via target env).
  • Bootstrap writes runner.shutdown_timeout = drain − 60 s (min 30 s) into runner.yaml, runs forgejo-runner daemon in the background and SIGTERMs it at deadline − drain. forgejo-runner 12.x then stops polling, finishes an in-flight job within shutdown_timeout, and exits → container stops → monitor destroys the instance.

Tests (internal/nsc/dispatcher_linux_test.go)

Phase-parser table using the JSON captured on the forge; waitForInstanceStop with a scripted fake nsc (startup window, gone-during-grace, transient errors, timeout, ctx cancel); drain-margin table; launch env assertions; the drain shell fragment executed under bash with a fake forgejo-runner (SIGTERM at the drain point, exit-status pass-through, shutdown_timeout buffer). The two pre-existing t.Setenv+t.Parallel launch tests panicked on Linux and are fixed. Full internal/nsc suite run on the forge (Linux, cross-compiled): all pass, timing tests stable ×3.

Rollout

key.store forgejo_nsc_src bump + forge redeploy follow; verification is the same journalctl census (instant destroys → ~0, jobs still get runners).

## Problem Measured on the forge (`journalctl -u forgejo-nsc-dispatcher`, 24h to 2026-08-16 10:00Z): **125 of 176** `runner launched` events were followed by `runner destroyed` within 2–5 s, across every machine type, while Forgejo showed those runners registered and online. Reproduced as root on the forge with `nsc run --wait --output json …; nsc describe --output json <id>`: | t after `nsc run --wait` returned | `nsc describe --output json` | |---|---| | +0.03 s | `per_resource.<uid>` has only `namespace/name/uid` — **no `container` key** | | +3.5 s | `container: [{status: running, ready: true, started_at: …}]` (started_at ≈ 4 s after `--wait` returned) | | +27 s (process exited) | `tombstone` set, container `status: stopped` + `terminated_at` | | +48 s | describe fails: `… failed to start or was destroyed (FailedPrecondition)` | `instanceStopped()` treated the empty container list as "all containers stopped" (`continue` → fall through → `return true`), so the first check in `waitForInstanceStop` destroyed the instance immediately. ## Fix **Stop monitor** (`internal/nsc/dispatcher.go`) - `describeInstancePhase()` classifies describe output as `unknown / starting / running / stopped`. Only a tombstone or an explicitly stopped/terminated container is `stopped`; a missing/empty container list is `starting`. - `waitForInstanceStop()` skips the immediate check (10 s initial delay), keeps polling through `starting`, tracks whether a container was ever observed, and only trusts a `not found`/`destroyed` describe error once a container was seen or a 2 m startup grace has passed. Transient describe errors are now retried until the TTL+5m deadline instead of ending the wait (which made the caller destroy a possibly mid-job runner). First check logs the raw describe JSON (`runner first stop check`); phase transitions are logged. - Intervals live in `stopMonitorConfig` so tests can shrink them. **TTL drain** (related hazard from the same investigation): daemon-mode runners accepted jobs right before the `nsc run --duration` TTL expired (job 97045 in run 17183 was killed when `nscloud-176cbcc386d5`, launched 08:47 with a 90 m TTL, hit 10:17). - `LaunchRunner` passes `FORGEJO_RUNNER_TTL_DEADLINE_EPOCH` (computed *before* `nsc run`, so never later than the real expiry) and `FORGEJO_RUNNER_DRAIN_SECONDS` (TTL/3 clamped to 5m..30m, ≤ TTL/2 → 30 m for 90 m TTL, 5 m for 15 m; overridable via target `env`). - Bootstrap writes `runner.shutdown_timeout = drain − 60 s` (min 30 s) into `runner.yaml`, runs `forgejo-runner daemon` in the background and SIGTERMs it at `deadline − drain`. forgejo-runner 12.x then stops polling, finishes an in-flight job within `shutdown_timeout`, and exits → container stops → monitor destroys the instance. ## Tests (`internal/nsc/dispatcher_linux_test.go`) Phase-parser table using the JSON captured on the forge; `waitForInstanceStop` with a scripted fake `nsc` (startup window, gone-during-grace, transient errors, timeout, ctx cancel); drain-margin table; launch env assertions; the drain shell fragment executed under bash with a fake `forgejo-runner` (SIGTERM at the drain point, exit-status pass-through, `shutdown_timeout` buffer). The two pre-existing `t.Setenv`+`t.Parallel` launch tests panicked on Linux and are fixed. Full `internal/nsc` suite run on the forge (Linux, cross-compiled): all pass, timing tests stable ×3. ## Rollout key.store `forgejo_nsc_src` bump + forge redeploy follow; verification is the same journalctl census (instant destroys → ~0, jobs still get runners).
dispatcher: do not treat a fresh instance's empty container list as stopped; drain daemon runners before TTL
Some checks are pending
Build Container / Image (amd64) (pull_request) Waiting to run
Build Container / Image (arm64) (pull_request) Waiting to run
Build Container / Publish Manifest (pull_request) Blocked by required conditions
Build Go / Go Test (namespace-profile-linux-large) (pull_request) Waiting to run
Build Go / Go Test (namespace-profile-macos-large) (pull_request) Waiting to run
Build Go / Nix Build (pull_request) Waiting to run
Build Container / Image (amd64) (push) Waiting to run
Build Container / Image (arm64) (push) Waiting to run
Build Container / Publish Manifest (push) Blocked by required conditions
Build Go / Go Test (namespace-profile-linux-large) (push) Waiting to run
Build Go / Go Test (namespace-profile-macos-large) (push) Waiting to run
Build Go / Nix Build (push) Waiting to run
82e3da075d
Measured on the forge (journalctl -u forgejo-nsc-dispatcher, 24h to
2026-08-16 10:00Z): 125 of 176 "runner launched" events were followed by
"runner destroyed" within 2-5 s, on every machine type, while Forgejo showed
the runner registered and online. Reproduced as root with
`nsc run --wait --output json ...; nsc describe --output json <id>`: for the
first ~3-6 s after `nsc run --wait` returns, describe reports the resource
with no `container` key at all; the container list only appears (status
"running") once the container has actually started. instanceStopped()
treated that empty list as "all containers stopped" (the loop `continue`d
and fell through to `return true`), so the very first check in
waitForInstanceStop destroyed the instance.

Stop monitor:
- describeInstancePhase() classifies describe output as unknown / starting /
  running / stopped. Only a tombstone or an explicitly stopped/terminated
  container yields stopped; a missing or empty container list is "starting".
  instanceStopped() is now a thin wrapper.
- waitForInstanceStop() skips the immediate check (10 s initial delay), keeps
  polling through "starting", tracks whether a container was ever observed,
  and only trusts a "not found"/"destroyed" describe error once a container
  was seen or a 2 m startup grace has passed. Transient describe errors are
  retried until the TTL+5m deadline instead of ending the wait (which made
  the caller destroy a possibly mid-job runner). The first check logs the
  raw describe JSON and later phase changes are logged.
- Intervals live in stopMonitorConfig so tests can shrink them.

TTL drain (same change, related hazard): daemon-mode runners accepted jobs
right before the `nsc run --duration` TTL expired (job 97045 in run 17183
was killed when nscloud-176cbcc386d5, launched 08:47 with a 90 m TTL, hit
10:17). LaunchRunner now passes FORGEJO_RUNNER_TTL_DEADLINE_EPOCH (computed
before `nsc run`, so never later than the real expiry) and
FORGEJO_RUNNER_DRAIN_SECONDS (TTL/3 clamped to 5m..30m, at most TTL/2;
callers can override via env). The bootstrap writes
runner.shutdown_timeout = drain-60s (min 30 s) into runner.yaml, runs
`forgejo-runner daemon` in the background and SIGTERMs it at
deadline-drain; forgejo-runner 12.x then stops polling, finishes an
in-flight job within shutdown_timeout, and exits, so the container stops
and the monitor destroys the instance.

Tests (internal/nsc/dispatcher_linux_test.go): phase parser table using the
JSON captured on the forge, waitForInstanceStop with a scripted fake nsc
(startup window, gone-during-grace, transient errors, timeout, ctx cancel),
drain margin table, launch env assertions, and the drain shell fragment run
under bash with a fake forgejo-runner (SIGTERM at the drain point, exit
status pass-through, shutdown_timeout buffer). The two pre-existing
t.Setenv+t.Parallel launch tests panicked on Linux and are fixed to bake
the log path into the fake nsc instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
founder deleted branch fix/stop-monitor-startup-window 2026-08-16 11:16:20 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
key-store/forgejo-nsc!1
No description provided.