From 58ca0ff8b2d3f68ddfc80fa3993226205ae3eb3f Mon Sep 17 00:00:00 2001 From: test Date: Thu, 6 Aug 2026 06:42:11 +0000 Subject: [PATCH 1/2] Two long-stale attach smokes assert the behaviour that replaced them (#655) `claude_attach_detach` and `client_attach_on_join` have both failed on master since long before v1.19.0. Neither is a code defect: each asserts a contract that a later, documented decision deliberately replaced, and neither smoke was updated with it. Both are in neither the release battery nor CI, so nothing caught the drift. claude_attach_detach broke at 41ea01c (LLP 0106, #316). Attach used to install exactly one managed hook group per event; LLP 0106 added `classify-cwd` beside `session-context` on the two events where a fresh working directory appears (SessionStart, CwdChanged), so `hooks.SessionStart` now carries two groups and the smoke's `v.length === 1` fails. The unit test for the marker's managed hook list was updated at the time; the on-disk golden compare in the smoke was not. Assert both sides of the split instead: SessionStart and CwdChanged carry the pair, UserPromptSubmit and PostToolUse carry `session-context` alone, so dropping either kind or leaking `classify-cwd` onto the per-prompt and per-tool events fails here. client_attach_on_join broke at 1ce40da (LLP 0086, #277/#278). Its step 3 asserted the pre-0086 model, attach once and done forever: a relaunch on an unchanged revision must not re-attach. LLP 0086 D1 made a `done` marker at a moved endpoint a forward gap precisely so an ephemeral gateway port does not strand `ANTHROPIC_BASE_URL` on a port nothing bound, and rev-1 binds `127.0.0.1:0`, so the relaunch drifts by construction and the marker is refreshed. Cover both branches of that check rather than one: the relaunch on rev-1 now asserts the re-attach and that the base URL follows the new port, then rev-1b pins the gateway's `listen` so the endpoint stops moving and a further relaunch proves the short-circuit the step originally meant to prove. Co-Authored-By: Claude --- .../smoke/flows/claude_attach_detach.js | 72 +++++-- .../smoke/flows/client_attach_on_join.js | 186 ++++++++++++++---- 2 files changed, 208 insertions(+), 50 deletions(-) diff --git a/hypaware-core/smoke/flows/claude_attach_detach.js b/hypaware-core/smoke/flows/claude_attach_detach.js index 17c5b1a1..3ff6febd 100644 --- a/hypaware-core/smoke/flows/claude_attach_detach.js +++ b/hypaware-core/smoke/flows/claude_attach_detach.js @@ -26,7 +26,9 @@ import { requireAiGatewayRuntime } from '../../plugins-workspace/ai-gateway/src/ * * - `hyp attach --client claude` patches `~/.claude/settings.json` * with the HypAware marker, `env.ANTHROPIC_BASE_URL`, and the - * managed session-context hook entries (golden compare). + * managed hook entries (golden compare): `session-context` on every + * managed event, plus `classify-cwd` on the two fresh-cwd events + * (LLP 0106). * - A `client.attach` span exists with `hyp_plugin=@hypaware/claude`, * `client_name=claude`, `status=ok`, `restored=false`. * - `hyp detach --client claude` removes the managed keys and the @@ -181,23 +183,46 @@ export async function run({ harness, expect }) { typeof v.state_file === 'string' && v.state_file.endsWith('session-context.jsonl') ) + // LLP 0106 split what attach installs on the session-start events: + // `session-context` still rides every managed event, and `classify-cwd` + // rides only the two events where a *fresh* working directory appears + // (SessionStart, CwdChanged). Both sides of the split are asserted here so + // dropping either kind, or leaking `classify-cwd` onto the per-prompt and + // per-tool events, fails the golden compare instead of passing quietly. + // @ref LLP 0106#decision [tests]: the classification hook rides the fresh-cwd events beside session-context, and only those expect.that( - 'settings: SessionStart hook installed with --state-file pointing at the plugin state dir', - attached?.hooks?.SessionStart, + 'settings: SessionStart carries session-context (with --state-file) then classify-cwd', + hookCommands(attached?.hooks?.SessionStart), (v) => - Array.isArray(v) && - v.length === 1 && - Array.isArray(v[0].hooks) && - v[0].hooks[0]?.type === 'command' && - typeof v[0].hooks[0]?.command === 'string' && - v[0].hooks[0].command.includes('claude-hook session-context') && - v[0].hooks[0].command.includes('--state-file ') && - v[0].hooks[0].command.includes('session-context.jsonl') + v.length === 2 && + v[0].includes('claude-hook session-context') && + v[0].includes('--state-file ') && + v[0].includes('session-context.jsonl') && + v[1].endsWith('claude-hook classify-cwd') + ) + expect.that( + 'settings: CwdChanged carries the same pair (the other fresh-cwd event)', + hookCommands(attached?.hooks?.CwdChanged), + (v) => + v.length === 2 && + v[0].includes('claude-hook session-context') && + v[0].includes('session-context.jsonl') && + v[1].endsWith('claude-hook classify-cwd') ) expect.that( - 'settings: PostToolUse hook scoped to Bash matcher', - attached?.hooks?.PostToolUse?.[0]?.matcher, - (v) => v === 'Bash' + 'settings: UserPromptSubmit carries session-context only (no re-ask per prompt)', + hookCommands(attached?.hooks?.UserPromptSubmit), + (v) => v.length === 1 && v[0].includes('claude-hook session-context') + ) + expect.that( + 'settings: PostToolUse carries session-context only, scoped to the Bash matcher', + attached?.hooks?.PostToolUse, + (v) => + Array.isArray(v) && + v.length === 1 && + v[0]?.matcher === 'Bash' && + hookCommands(v).length === 1 && + hookCommands(v)[0].includes('claude-hook session-context') ) // Drive `hyp detach --client claude` through the dispatcher. @@ -295,6 +320,25 @@ export async function run({ harness, expect }) { } } +/** + * Flatten a `hooks.` block into its command strings, in install order. + * Each element of the block is a `{ matcher?, hooks: [{ type, command }] }` + * group, and attach pushes one group per command kind the event carries. + * + * @param {unknown} groups + * @returns {string[]} + */ +function hookCommands(groups) { + if (!Array.isArray(groups)) return [] + return groups.flatMap((group) => { + const handlers = group?.hooks + if (!Array.isArray(handlers)) return [] + return handlers + .filter((/** @type {any} */ h) => h?.type === 'command' && typeof h.command === 'string') + .map((/** @type {any} */ h) => /** @type {string} */ (h.command)) + }) +} + /** * @param {{ hypHome: string }} harness */ diff --git a/hypaware-core/smoke/flows/client_attach_on_join.js b/hypaware-core/smoke/flows/client_attach_on_join.js index 540d499e..1f4ecb47 100644 --- a/hypaware-core/smoke/flows/client_attach_on_join.js +++ b/hypaware-core/smoke/flows/client_attach_on_join.js @@ -28,10 +28,16 @@ import { dispatch } from '../../../src/core/cli/dispatch.js' * schedules a reconcile pass → **claude auto-attaches**: the `_hypaware` * marker + the gateway `ANTHROPIC_BASE_URL` land in the client settings, * and the `attach.claude` client-action marker reads `done`. - * 3. a second confirmed boot pass (a fresh relaunch on the same rev-1) is a - * **no-op**: the `done` marker short-circuits, so the attach is not - * re-applied (the marker timestamp is unchanged). - * 4. the server drops `@hypaware/claude` (rev-2) → apply → staged restart → + * 3. a second confirmed boot pass (a fresh relaunch on the same rev-1) hits + * the **drift** branch of the freshness check: rev-1 lets the gateway bind + * an ephemeral port, so the relaunch is at a *new* endpoint, the `done` + * marker is stale, and the forward gap re-attaches at the new port. + * 4. rev-1b pins the gateway's `listen` → apply → staged restart → relaunch + * re-attaches once at the pinned port; a further relaunch at that same, + * now-stable endpoint is the **no-op** branch: the `done` marker + * short-circuits and nothing is re-applied (the marker timestamp and the + * client settings are unchanged). + * 5. the server drops `@hypaware/claude` (rev-2) → apply → staged restart → * relaunch without the adapter → the reconcile **reverse gap** runs the * disk-driven undo: the marker is removed and the client settings are * restored to their pre-attach state, the Part 5 config-drop trigger, @@ -44,6 +50,7 @@ import { dispatch } from '../../../src/core/cli/dispatch.js' * @ref LLP 0045#part-1-the-client-seam-in-the-reconcile-context [tests]: the daemon threads clientDescriptors/clients/endpoint onto the reconcile context; a confirm-edge pass reaches the attach handler * @ref LLP 0045#part-5-reverse-triggers-config-drop-not-hyp-leave [tests]: a central config drop reverses the attach post-restart via the disk-driven undo * @ref LLP 0044#consent-join-implies-consent-default-on [tests]: a joined host confirming a config that names @hypaware/claude auto-attaches (default-on) + * @ref LLP 0086#re-attach-on-drift [tests]: both branches of the freshness check, a relaunch at a rebound ephemeral port re-attaches, a relaunch at a pinned one short-circuits */ export async function run({ harness, expect }) { const obs = installObservability() @@ -71,11 +78,18 @@ export async function run({ harness, expect }) { const localConfigPath = defaultConfigPath(harness.hypHome) const stateRoot = path.join(harness.hypHome, 'hypaware') + // The freshness check watches the gateway's live endpoint, so the smoke needs + // both a moving one (rev-1's ephemeral bind) and a stable one (rev-1b's pin). + const pinnedPort = await reserveLocalPort() + const pinnedListen = `127.0.0.1:${pinnedPort}` + const pinnedEndpoint = `http://${pinnedListen}` + const server = await startStubCentralServer() try { // rev-1: a joined fleet config that enables the gateway + the claude client - // adapter. Confirming it must auto-attach claude. - server.setConfig(rev1Config(server.baseUrl), 'rev-1') + // adapter. Confirming it must auto-attach claude. Its gateway binds an + // ephemeral port, which is what makes the relaunch below drift. + server.setConfig(rev1Config(server.baseUrl, EPHEMERAL_LISTEN), 'rev-1') // An empty local layer so `join` has something to leave untouched. await fs.writeFile(localConfigPath, JSON.stringify({ version: 2, plugins: [] }, null, 2) + '\n') @@ -139,14 +153,97 @@ export async function run({ harness, expect }) { await attachHandle.done } - // Snapshot the post-attach state for the idempotency assertion below. + // Snapshot the post-attach state for the drift assertions below. const attachedAt = attachMarker(stateRoot)?.at - const attachedBody = await fs.readFile(claudeSettingsPath, 'utf8') + const attachedEndpoint = attachMarker(stateRoot)?.endpoint - // ----- smoke_step: no_reattach (a second confirmed boot pass is a no-op) ----- + // ----- smoke_step: reattach_on_drift (relaunch rev-1 → new port → re-attach) ----- // A fresh relaunch on the *same* rev-1 runs the after-activation // already-confirmed pass (probation is cleared), so desired() names claude - // again, but the `done` marker short-circuits, so nothing is re-applied. + // again and the `done` marker is consulted. rev-1's gateway binds an + // ephemeral port, so this boot is at a *different* endpoint: the marker is + // stale, the unit is a forward gap, and the attach re-performs at the new + // port instead of short-circuiting forever. + // @ref LLP 0086#re-attach-on-drift [tests]: a done marker at a moved endpoint re-performs, which is what keeps ANTHROPIC_BASE_URL pointing at a bound port + const driftHandle = await runDaemonHandle(harness) + try { + await waitFor( + () => readConfigControlStatus({ stateRoot }).probation === null, + 15_000, + 'probation was unexpectedly re-armed on the drift relaunch' + ) + await waitFor( + () => { + const marker = attachMarker(stateRoot) + return marker?.status === 'done' && marker.at !== attachedAt + }, + 15_000, + 'the attach.claude marker was not refreshed after the gateway rebound to a new port' + ) + const drifted = attachMarker(stateRoot) + expect.that( + 'drift: the refreshed marker records the newly bound endpoint', + drifted?.endpoint, + (v) => typeof v === 'string' && v.length > 0 && v !== attachedEndpoint + ) + const rewritten = JSON.parse(await fs.readFile(claudeSettingsPath, 'utf8')) + expect.that( + 'drift: env.ANTHROPIC_BASE_URL was rewritten to the newly bound port', + rewritten?.env?.ANTHROPIC_BASE_URL, + (v) => typeof v === 'string' && v === drifted?.endpoint + ) + expect.that( + 'drift: the unrelated seed key (ANTHROPIC_API_KEY) survived the re-attach', + rewritten?.env?.ANTHROPIC_API_KEY, + (v) => v === 'sk-seed' + ) + + // ----- smoke_step: pin_port (serve rev-1b → apply → restart) ----- + // rev-1b is rev-1 with the gateway's `listen` pinned, so every later boot + // binds the same port: the input the freshness check watches stops moving. + server.setConfig(rev1Config(server.baseUrl, pinnedListen), 'rev-1b') + const pinExit = await withTimeout( + driftHandle.done, + 30_000, + 'the rev-1b pinned-port revision did not request a staged restart within 30s' + ) + expect.that( + `pin: daemon exited with the restart code (got ${pinExit})`, + pinExit, + (v) => v === DAEMON_RESTART_EXIT_CODE + ) + } finally { + // `driftHandle.done` already resolved (restart): stop() is idempotent. + await driftHandle.stop() + } + + // ----- smoke_step: pinned_attach (relaunch rev-1b → re-attach at the pinned port) ----- + const pinnedHandle = await runDaemonHandle(harness) + try { + await waitFor( + () => readConfigControlStatus({ stateRoot }).probation === null, + 15_000, + 'probation did not clear within 15s of the rev-1b relaunch' + ) + await waitFor( + () => attachMarker(stateRoot)?.endpoint === pinnedEndpoint, + 15_000, + `the attach.claude marker did not move to the pinned endpoint ${pinnedEndpoint}` + ) + } finally { + await pinnedHandle.stop() + await pinnedHandle.done + } + + // Snapshot the pinned-port state for the no-op assertions below. + const pinnedAt = attachMarker(stateRoot)?.at + const pinnedBody = await fs.readFile(claudeSettingsPath, 'utf8') + + // ----- smoke_step: no_reattach (a boot at an unchanged endpoint is a no-op) ----- + // The complement of the drift branch: rev-1b's port is fixed, so this boot + // resolves the same endpoint the marker records, the freshness check calls + // the marker current, and the `done` marker short-circuits as it always did. + // @ref LLP 0086#re-attach-on-drift [tests]: the guard side of the same check, an unmoved endpoint still short-circuits rather than churning the attach every boot const steadyHandle = await runDaemonHandle(harness) try { await waitFor( @@ -160,18 +257,18 @@ export async function run({ harness, expect }) { expect.that( 'no re-attach: the attach.claude marker timestamp is unchanged (done short-circuits)', attachMarker(stateRoot)?.at, - (v) => v === attachedAt + (v) => v === pinnedAt ) expect.that( 'no re-attach: the client settings are byte-for-byte unchanged', await fs.readFile(claudeSettingsPath, 'utf8'), - (v) => v === attachedBody + (v) => v === pinnedBody ) // ----- smoke_step: drop_claude (serve rev-2 → apply → restart) ----- // rev-2 drops @hypaware/claude fleet-wide; the running daemon's next poll // applies it and requests a staged restart. - server.setConfig(rev2Config(server.baseUrl), 'rev-2') + server.setConfig(rev2Config(server.baseUrl, pinnedListen), 'rev-2') const dropExit = await withTimeout( steadyHandle.done, 30_000, @@ -256,21 +353,17 @@ export async function run({ harness, expect }) { /* ---------- served revisions ---------- */ -/** @param {string} baseUrl */ -function rev1Config(baseUrl) { +// The default gateway bind: a port the kernel picks fresh on every boot, which +// is what makes a relaunch on an unchanged revision drift (LLP 0086). +const EPHEMERAL_LISTEN = '127.0.0.1:0' + +/** @param {string} baseUrl @param {string} listen */ +function rev1Config(baseUrl, listen) { return { version: 2, plugins: [ { name: '@hypaware/central' }, - { - name: '@hypaware/ai-gateway', - config: { - listen: '127.0.0.1:0', - upstreams: [ - { name: 'anthropic', base_url: 'https://api.anthropic.com', path_prefix: '/' }, - ], - }, - }, + { name: '@hypaware/ai-gateway', config: gatewayConfig(listen) }, { name: '@hypaware/claude' }, ], sinks: centralSink(baseUrl), @@ -278,27 +371,33 @@ function rev1Config(baseUrl) { } } -/** rev-2 is rev-1 minus the claude client plugin: the fleet-drop trigger. @param {string} baseUrl */ -function rev2Config(baseUrl) { +/** + * rev-2 is rev-1 minus the claude client plugin: the fleet-drop trigger. + * @param {string} baseUrl + * @param {string} listen + */ +function rev2Config(baseUrl, listen) { return { version: 2, plugins: [ { name: '@hypaware/central' }, - { - name: '@hypaware/ai-gateway', - config: { - listen: '127.0.0.1:0', - upstreams: [ - { name: 'anthropic', base_url: 'https://api.anthropic.com', path_prefix: '/' }, - ], - }, - }, + { name: '@hypaware/ai-gateway', config: gatewayConfig(listen) }, ], sinks: centralSink(baseUrl), query: { cache: { retention: { default_days: 30 } } }, } } +/** @param {string} listen */ +function gatewayConfig(listen) { + return { + listen, + upstreams: [ + { name: 'anthropic', base_url: 'https://api.anthropic.com', path_prefix: '/' }, + ], + } +} + /** @param {string} baseUrl */ function centralSink(baseUrl) { return { @@ -344,10 +443,25 @@ async function runDaemonHandle(harness) { }) } +/** + * Reserve a free loopback port by binding one and releasing it. Pinning the + * gateway's `listen` to it is what holds the endpoint still across a relaunch, + * the branch of the freshness check where a `done` marker still short-circuits. + * @returns {Promise} + */ +async function reserveLocalPort() { + const probe = http.createServer() + await new Promise((resolve) => probe.listen(0, '127.0.0.1', () => resolve(undefined))) + const address = /** @type {AddressInfo} */ (probe.address()) + const { port } = address + await new Promise((resolve) => probe.close(() => resolve(undefined))) + return port +} + /** * Read the `attach.claude` client-action marker, or `undefined` when absent. * @param {string} stateRoot - * @returns {{ status?: string, request_key?: string, at?: string } | undefined} + * @returns {{ status?: string, request_key?: string, at?: string, endpoint?: string } | undefined} */ function attachMarker(stateRoot) { const byKind = readClientActionStatus({ stateRoot }).byKind From d55a90b751ee3b9ff6c1527daeb4995962160151 Mon Sep 17 00:00:00 2001 From: test Date: Thu, 6 Aug 2026 07:33:36 +0000 Subject: [PATCH 2/2] Review fixes: race-free port pin, honest LLP 0106 gloss (#655) client_attach_on_join reserved rev-1b's pinned port by binding port 0 and releasing it, then let the daemon bind it seconds later. Anything on the host could win that window, including the smoke's own stub central server, which made this the only flow in the suite that could collide with a co-resident process. Read the port back from the drifted daemon's own status.json instead (resolveLiveGatewayEndpointFromStatus, LLP 0086 D2) and pin rev-1b to that. The daemon then reclaims a port it already holds across the staged restart, so no reservation window opens at all. Pinning a port the daemon already bound means the relaunch short-circuits rather than re-attaching, so the separate pinned_attach boot is folded into the no_reattach one. The drift step still proves the re-attach branch and still asserts env.ANTHROPIC_BASE_URL === drifted.endpoint, and the short-circuit comparison still runs against a genuinely fresh marker timestamp: the drift step asserted it moved off the first attach's. The guard boot now also proves the gateway really reclaimed the pinned port, matched against this boot's status.json rather than the outgoing daemon's leftover snapshot (a pinned port makes both report the same endpoint). claude_attach_detach's LLP 0106 gloss claimed the doc scopes the classification hook to the fresh-cwd events. It does not: 0106 settles only that the hook is installed alongside session-context, and the event scoping is decided by MANAGED_HOOK_SPECS in the claude plugin's settings.js. Narrow the gloss to what 0106 says and point the scoping at the code that owns it. Co-Authored-By: Claude --- .../smoke/flows/claude_attach_detach.js | 23 ++-- .../smoke/flows/client_attach_on_join.js | 117 ++++++++++++------ 2 files changed, 90 insertions(+), 50 deletions(-) diff --git a/hypaware-core/smoke/flows/claude_attach_detach.js b/hypaware-core/smoke/flows/claude_attach_detach.js index 3ff6febd..dc693101 100644 --- a/hypaware-core/smoke/flows/claude_attach_detach.js +++ b/hypaware-core/smoke/flows/claude_attach_detach.js @@ -27,8 +27,8 @@ import { requireAiGatewayRuntime } from '../../plugins-workspace/ai-gateway/src/ * - `hyp attach --client claude` patches `~/.claude/settings.json` * with the HypAware marker, `env.ANTHROPIC_BASE_URL`, and the * managed hook entries (golden compare): `session-context` on every - * managed event, plus `classify-cwd` on the two fresh-cwd events - * (LLP 0106). + * managed event, plus the LLP 0106 `classify-cwd` hook, which the + * plugin scopes to the two fresh-cwd events. * - A `client.attach` span exists with `hyp_plugin=@hypaware/claude`, * `client_name=claude`, `status=ok`, `restored=false`. * - `hyp detach --client claude` removes the managed keys and the @@ -183,13 +183,18 @@ export async function run({ harness, expect }) { typeof v.state_file === 'string' && v.state_file.endsWith('session-context.jsonl') ) - // LLP 0106 split what attach installs on the session-start events: - // `session-context` still rides every managed event, and `classify-cwd` - // rides only the two events where a *fresh* working directory appears - // (SessionStart, CwdChanged). Both sides of the split are asserted here so - // dropping either kind, or leaking `classify-cwd` onto the per-prompt and - // per-tool events, fails the golden compare instead of passing quietly. - // @ref LLP 0106#decision [tests]: the classification hook rides the fresh-cwd events beside session-context, and only those + // LLP 0106 settles that attach installs the classification hook *alongside* + // the existing session-context hook, which is what makes a golden compare + // expecting session-context on its own stale. + // + // Which events each kind rides is not 0106's to say and is not stated + // there: `session-context` on every managed event, `classify-cwd` only + // where a *fresh* working directory appears (SessionStart, CwdChanged), is + // decided by `MANAGED_HOOK_SPECS` in the claude plugin's `src/settings.js`, + // with the reasoning in the comment above it. Both sides are asserted here + // so dropping either kind, or leaking `classify-cwd` onto the per-prompt + // and per-tool events, fails the golden compare instead of passing quietly. + // @ref LLP 0106#decision [tests]: attach installs the classification hook beside the session-context hook expect.that( 'settings: SessionStart carries session-context (with --state-file) then classify-cwd', hookCommands(attached?.hooks?.SessionStart), diff --git a/hypaware-core/smoke/flows/client_attach_on_join.js b/hypaware-core/smoke/flows/client_attach_on_join.js index 1f4ecb47..214d3641 100644 --- a/hypaware-core/smoke/flows/client_attach_on_join.js +++ b/hypaware-core/smoke/flows/client_attach_on_join.js @@ -10,6 +10,7 @@ import { defaultConfigPath } from '../../../src/core/config/schema.js' import { readConfigControlStatus } from '../../../src/core/config/apply.js' import { readClientActionStatus } from '../../../src/core/config/action_reconciler.js' import { DAEMON_RESTART_EXIT_CODE, runDaemon } from '../../../src/core/daemon/runtime.js' +import { readStatusFile, resolveLiveGatewayEndpointFromStatus } from '../../../src/core/daemon/status.js' import { dispatch } from '../../../src/core/cli/dispatch.js' /** @@ -32,11 +33,11 @@ import { dispatch } from '../../../src/core/cli/dispatch.js' * the **drift** branch of the freshness check: rev-1 lets the gateway bind * an ephemeral port, so the relaunch is at a *new* endpoint, the `done` * marker is stale, and the forward gap re-attaches at the new port. - * 4. rev-1b pins the gateway's `listen` → apply → staged restart → relaunch - * re-attaches once at the pinned port; a further relaunch at that same, - * now-stable endpoint is the **no-op** branch: the `done` marker - * short-circuits and nothing is re-applied (the marker timestamp and the - * client settings are unchanged). + * 4. rev-1b pins the gateway's `listen` to the port the drifted daemon is + * already bound to (read back from its own `status.json`) → apply → + * staged restart → the relaunch reclaims that same port, so it is the + * **no-op** branch: the `done` marker short-circuits and nothing is + * re-applied (the marker timestamp and the client settings are unchanged). * 5. the server drops `@hypaware/claude` (rev-2) → apply → staged restart → * relaunch without the adapter → the reconcile **reverse gap** runs the * disk-driven undo: the marker is removed and the client settings are @@ -80,9 +81,15 @@ export async function run({ harness, expect }) { // The freshness check watches the gateway's live endpoint, so the smoke needs // both a moving one (rev-1's ephemeral bind) and a stable one (rev-1b's pin). - const pinnedPort = await reserveLocalPort() - const pinnedListen = `127.0.0.1:${pinnedPort}` - const pinnedEndpoint = `http://${pinnedListen}` + // The pinned value is deliberately *not* reserved up front: it is read back + // from the running daemon's status.json below, so rev-1b pins a port the + // daemon is already holding rather than one this process probed and released. + /** @type {string} */ + let pinnedListen = '' + /** @type {string} */ + let pinnedEndpoint = '' + /** @type {string | undefined} */ + let driftStartedAt const server = await startStubCentralServer() try { @@ -201,6 +208,32 @@ export async function run({ harness, expect }) { // ----- smoke_step: pin_port (serve rev-1b → apply → restart) ----- // rev-1b is rev-1 with the gateway's `listen` pinned, so every later boot // binds the same port: the input the freshness check watches stops moving. + // + // The port it pins is the one *this* daemon is bound to right now, read + // back out of its own status.json. That is what keeps the pin race-free: + // probing a free port by binding and releasing it would hand rev-1b a + // port nobody holds and hope it is still free seconds later (any + // co-resident process, including this smoke's own stub server, could take + // it), whereas a port the daemon already owns is simply reclaimed across + // the staged restart. + // @ref LLP 0086#endpoint-discovery [tests]: the live bound port is readable from status.json, which is what lets the pin name a port the daemon already holds + driftStartedAt = statusStartedAt(stateRoot) + const liveEndpoint = resolveLiveGatewayEndpointFromStatus({ stateRoot }) + expect.that( + 'pin: the drifted gateway reports its live bound endpoint in status.json', + liveEndpoint, + (v) => typeof v === 'string' && v === drifted?.endpoint + ) + // Not decoration: the relaunch below is told apart from this boot's + // leftover snapshot by `startedAt`, so an unread one would make that + // check pass on stale data. + expect.that( + 'pin: this boot is identifiable in status.json by its startedAt', + driftStartedAt, + (v) => typeof v === 'string' && v.length > 0 + ) + pinnedEndpoint = String(liveEndpoint) + pinnedListen = pinnedEndpoint.slice('http://'.length) server.setConfig(rev1Config(server.baseUrl, pinnedListen), 'rev-1b') const pinExit = await withTimeout( driftHandle.done, @@ -217,32 +250,17 @@ export async function run({ harness, expect }) { await driftHandle.stop() } - // ----- smoke_step: pinned_attach (relaunch rev-1b → re-attach at the pinned port) ----- - const pinnedHandle = await runDaemonHandle(harness) - try { - await waitFor( - () => readConfigControlStatus({ stateRoot }).probation === null, - 15_000, - 'probation did not clear within 15s of the rev-1b relaunch' - ) - await waitFor( - () => attachMarker(stateRoot)?.endpoint === pinnedEndpoint, - 15_000, - `the attach.claude marker did not move to the pinned endpoint ${pinnedEndpoint}` - ) - } finally { - await pinnedHandle.stop() - await pinnedHandle.done - } - - // Snapshot the pinned-port state for the no-op assertions below. + // Snapshot the post-drift state for the no-op assertions below. `pinnedAt` + // is a genuinely fresh timestamp, not a leftover: the drift step above + // asserted the re-attach moved the marker off `attachedAt`. const pinnedAt = attachMarker(stateRoot)?.at const pinnedBody = await fs.readFile(claudeSettingsPath, 'utf8') // ----- smoke_step: no_reattach (a boot at an unchanged endpoint is a no-op) ----- - // The complement of the drift branch: rev-1b's port is fixed, so this boot - // resolves the same endpoint the marker records, the freshness check calls - // the marker current, and the `done` marker short-circuits as it always did. + // The complement of the drift branch: rev-1b pins the port the drift boot + // bound, so this relaunch resolves the same endpoint the marker records, + // the freshness check calls the marker current, and the `done` marker + // short-circuits as it always did. // @ref LLP 0086#re-attach-on-drift [tests]: the guard side of the same check, an unmoved endpoint still short-circuits rather than churning the attach every boot const steadyHandle = await runDaemonHandle(harness) try { @@ -251,6 +269,20 @@ export async function run({ harness, expect }) { 15_000, 'probation was unexpectedly re-armed on the steady relaunch' ) + // The pin only holds the endpoint still if the gateway actually reclaimed + // the port it released on the staged restart, so read that back off *this* + // boot's status.json (`startedAt` moved) before calling the no-op below a + // no-op. A gateway that failed to rebind, or fell back to another port + // (LLP 0114), would otherwise look identical to a clean short-circuit. + await waitFor( + () => { + const startedAt = statusStartedAt(stateRoot) + if (startedAt === undefined || startedAt === driftStartedAt) return false + return resolveLiveGatewayEndpointFromStatus({ stateRoot }) === pinnedEndpoint + }, + 15_000, + `the relaunched gateway did not reclaim the pinned endpoint ${pinnedEndpoint}` + ) // Give the boot-already-confirmed pass time to run (and prove it does not // re-attach): the marker timestamp must be identical. await sleep(500) @@ -444,18 +476,21 @@ async function runDaemonHandle(harness) { } /** - * Reserve a free loopback port by binding one and releasing it. Pinning the - * gateway's `listen` to it is what holds the endpoint still across a relaunch, - * the branch of the freshness check where a `done` marker still short-circuits. - * @returns {Promise} + * The `startedAt` of the daemon boot that wrote the current `status.json`, or + * `undefined` when there is no readable status file yet. + * + * Which boot wrote a status file matters here because a pinned port makes two + * consecutive boots report the *same* endpoint: an endpoint read on its own + * cannot tell a fresh bind from the outgoing daemon's leftover snapshot. + * @param {string} stateRoot + * @returns {string | undefined} */ -async function reserveLocalPort() { - const probe = http.createServer() - await new Promise((resolve) => probe.listen(0, '127.0.0.1', () => resolve(undefined))) - const address = /** @type {AddressInfo} */ (probe.address()) - const { port } = address - await new Promise((resolve) => probe.close(() => resolve(undefined))) - return port +function statusStartedAt(stateRoot) { + try { + return readStatusFile(stateRoot)?.startedAt + } catch { + return undefined + } } /**