|
| 1 | +# bugfix-1261 — Tower serves API before internal wiring completes |
| 2 | + |
| 3 | +## Investigate |
| 4 | + |
| 5 | +Issue: `DELETE /api/terminals/:id` 404s for an existing terminal during Tower's |
| 6 | +startup window, because the whole boot sequence lives inside the |
| 7 | +`server.listen()` callback and `initInstances()` (which sets `_deps`) is the |
| 8 | +*last* async step. `killTerminalWithShellper()` bails with `false` when |
| 9 | +`_deps` is null; the DELETE route maps that to 404. |
| 10 | + |
| 11 | +Confirmed by reading: |
| 12 | +- `tower-server.ts:375` — `server.listen(...)` callback holds the entire boot. |
| 13 | +- `tower-server.ts:575` — `initInstances()` is last, after stale-socket cleanup, |
| 14 | + consolidation, `reconcileTerminalSessions()`, `killOrphanedShellpers()`, |
| 15 | + the #1227 husk sweep, and the #1238 session-log retention scan. |
| 16 | +- `tower-instances.ts:800` — `killTerminalWithShellper` → `if (!_deps) return false`. |
| 17 | +- `tower-routes.ts:842` — DELETE translates that `false` into 404 NOT_FOUND. |
| 18 | + |
| 19 | +Extra finding not in the issue: `afx tower start` treats a 200 from |
| 20 | +`/api/status` as readiness (`commands/tower.ts:110`), and `/api/status` |
| 21 | +answers 200 during the window (`getInstances()` returns `[]` when `_deps` is |
| 22 | +null). So the CLI's "Tower started" is a *false* readiness signal — that is |
| 23 | +what makes `afx tower start && <immediate afx cmd>` racy. |
| 24 | + |
| 25 | +### Reproduced |
| 26 | + |
| 27 | +Wrote a scratchpad repro that tight-loop-connects to the port and fires |
| 28 | +create+DELETE the instant it binds (the e2e helper's 200ms poll adds slack that |
| 29 | +usually hides it). On this machine, with an *empty* log dir: |
| 30 | + |
| 31 | +``` |
| 32 | +POST /api/terminals -> 201 (269ms after port open) |
| 33 | +DELETE /api/terminals/:id -> 404 {"error":"NOT_FOUND", ...} |
| 34 | +GET /api/terminals/:id -> 200 <-- terminal exists; the 404 was spurious |
| 35 | +``` |
| 36 | + |
| 37 | +Deterministic, 100% of runs. So the window is not exotic — it just needs a |
| 38 | +client that doesn't sleep before its first request. |
| 39 | + |
| 40 | +Planned fix (three parts, all small): |
| 41 | +1. Readiness gate in `tower-server.ts`: `server.listen()` still binds first |
| 42 | + (preserves EADDRINUSE detection + the single-Tower mutex), but |
| 43 | + `handleRequest` awaits a boot-complete promise before dispatching, with a |
| 44 | + bounded timeout → 503 + `Retry-After`. |
| 45 | +2. Reorder boot: move `initInstances()` up to right after the ordering- |
| 46 | + constrained steps; push the disk-scaling husk sweep + log retention sweep |
| 47 | + after it. Shrinks the window from O(disk) to O(process scan). |
| 48 | +3. Defense in depth: DELETE (and any `_deps`-dependent route) returns 503 |
| 49 | + "Tower is starting up" instead of 404, generalizing what `stopInstance` |
| 50 | + already does. |
| 51 | + |
| 52 | +## Fix |
| 53 | + |
| 54 | +All three parts implemented. |
| 55 | + |
| 56 | +- `tower-server.ts`: readiness gate (`bootComplete` + `whenBootComplete`) in |
| 57 | + the `http.createServer` handler; the listen callback now just logs and kicks |
| 58 | + off a named `bootSequence()`. Boot-throw still exits the process (was an |
| 59 | + unhandled rejection before, same net effect). Held requests get 503 + |
| 60 | + `Retry-After` if boot exceeds 20s. |
| 61 | +- `tower-server.ts`: `initInstances()` + `initCron()` moved up to right after |
| 62 | + `killOrphanedShellpers()`; the #1227 husk sweep, the #1238 log-retention |
| 63 | + sweep, and `initTunnel()` now run *after* the gate opens. Tunnel especially: |
| 64 | + gating local readiness on a remote connect would make an unreachable cloud |
| 65 | + endpoint look like a broken Tower. Measured effect on this machine: boot |
| 66 | + reaches ready at ~90ms instead of running the disk scans first. |
| 67 | +- `tower-instances.ts`: new `instancesReady()`; `tower-routes.ts`: DELETE |
| 68 | + `/api/terminals/:id` and the workspace tab-delete path return 503 instead of |
| 69 | + 404 / a lying 204 when the module isn't wired. |
| 70 | +- Test hook `AF_TEST_BOOT_DELAY_MS` widens the window deterministically — |
| 71 | + without it the race depends on this machine's process table and log volume, |
| 72 | + which is exactly why CI never saw it. |
| 73 | + |
| 74 | +### Verification |
| 75 | + |
| 76 | +Scratchpad repro, same invocation as before the fix: |
| 77 | + |
| 78 | +``` |
| 79 | +POST /api/terminals -> 201 |
| 80 | +DELETE /api/terminals/:id -> 204 (was 404) |
| 81 | +GET /api/terminals/:id -> 404 (was 200 — i.e. it really is gone now) |
| 82 | +``` |
| 83 | + |
| 84 | +New `tower-startup-readiness.e2e.test.ts` (2 tests) passes. Confirmed it |
| 85 | +*fails* with the gate disabled in dist: DELETE 503 (the second-line guard |
| 86 | +firing) and the status-hold assertion 2ms vs the required ≥1200ms. |
| 87 | + |
| 88 | +Known adjacent case left alone: WebSocket upgrades bypass the gate |
| 89 | +(`setupUpgradeHandler` attaches its own listener). Not reachable in practice — |
| 90 | +every client does an HTTP call first — so out of BUGFIX scope; noted in the PR. |
| 91 | + |
| 92 | +## PR |
| 93 | + |
| 94 | +PR #1263 — "[Bugfix #1261] Hold Tower API requests until boot wiring completes". |
| 95 | + |
| 96 | +CMAP (3-way, `--issue 1261 --project-id bugfix-1261`; note the bare |
| 97 | +`consult --protocol bugfix --type pr` form bails with "Multiple projects |
| 98 | +found" in this repo — it needs the issue/project flags): |
| 99 | + |
| 100 | +| Model | Verdict | Confidence | Key issues | |
| 101 | +|--------|---------|------------|------------| |
| 102 | +| gemini | APPROVE | HIGH | None | |
| 103 | +| codex | APPROVE | HIGH | None | |
| 104 | +| claude | APPROVE | HIGH | None | |
| 105 | + |
| 106 | +No REQUEST_CHANGES, so nothing to address or rebut. Claude raised two |
| 107 | +non-blocking observations and dismissed both itself: the `waitForPortImmediate` |
| 108 | +name, and whether the `elapsed >= BOOT_DELAY_MS * 0.8` assertion is CI-timing |
| 109 | +sensitive (it isn't — a slow machine only makes `>=` more true). |
| 110 | + |
| 111 | +Awaiting the human `pr` gate. |
0 commit comments