diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 080b95e4d..f2ab791bf 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -200,6 +200,11 @@ across pretest, Playwright, and posttest. `scripts/hot-dev.sh` must honor that lock by suppressing source-triggered rebuilds and manual `pulse` binary restart churn while the owning proof process is still alive. Stale verify locks must clear themselves automatically once the owning process exits. +That same verification contract also applies before Playwright attaches: if a +managed hot-dev session is already running when the verify lock is active, the +integration launcher must restart that session instead of silently attaching to +an old frontend process, so browser proof reflects the current branch-tip +source rather than whatever Vite shell happened to be left alive. That same launcher boundary also owns its CLI contract: managed commands such as `start --takeover` and `restart --takeover` must preserve the takeover flag through the actual script entrypoint instead of silently dropping second-arg diff --git a/scripts/tests/test-hot-dev-bg.sh b/scripts/tests/test-hot-dev-bg.sh index ab2da68f4..1a7b8101c 100755 --- a/scripts/tests/test-hot-dev-bg.sh +++ b/scripts/tests/test-hot-dev-bg.sh @@ -243,6 +243,36 @@ test_verify_bg_holds_runtime_lock_for_proof_duration() { assert_contains "verify clears the runtime lock after proofs finish" "${output}" "lock_after=no" } +test_managed_dev_runtime_restarts_existing_session_for_verification() { + local output + output="$( + ROOT_DIR="${ROOT_DIR}" \ + node --input-type=module <<'EOF' +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +const rootDir = process.env.ROOT_DIR; +const modulePath = path.join(rootDir, 'tests', 'integration', 'scripts', 'managed-dev-runtime.mjs'); +const { shouldRestartManagedDevRuntimeForVerification } = await import(`file://${modulePath}`); + +const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pulse-managed-dev-verify-')); +const lockPath = path.join(tempDir, 'hot-dev.verify.lock'); +await fs.writeFile(lockPath, `pid=${process.pid}\ncreated_at=2026-03-28T23:00:00Z\n`, 'utf8'); + +console.log( + `running=${shouldRestartManagedDevRuntimeForVerification({ env: { HOT_DEV_VERIFY_LOCK_FILE: lockPath }, wasRunning: true })}`, +); +console.log( + `stopped=${shouldRestartManagedDevRuntimeForVerification({ env: { HOT_DEV_VERIFY_LOCK_FILE: lockPath }, wasRunning: false })}`, +); +EOF + )" + + assert_contains "managed dev runtime restarts existing sessions during verification" "${output}" "running=true" + assert_contains "managed dev runtime does not restart absent prior session" "${output}" "stopped=false" +} + test_takeover_avoids_killing_current_shell_lineage() { local output output="$( @@ -865,6 +895,7 @@ main() { test_verify_command_injects_managed_runtime_env test_default_verify_command_runs_runtime_and_layout_proofs test_verify_bg_holds_runtime_lock_for_proof_duration + test_managed_dev_runtime_restarts_existing_session_for_verification test_takeover_avoids_killing_current_shell_lineage test_launchd_session_supervises_managed_runtime test_start_bg_reports_browser_entrypoint diff --git a/tests/integration/scripts/managed-dev-runtime.mjs b/tests/integration/scripts/managed-dev-runtime.mjs index fd0cbb53c..d389ce594 100644 --- a/tests/integration/scripts/managed-dev-runtime.mjs +++ b/tests/integration/scripts/managed-dev-runtime.mjs @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import { spawn } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -18,6 +19,10 @@ function hotDevBgScriptPath(env = process.env) { return path.join(repoRootFromEnv(env), 'scripts', 'hot-dev-bg.sh'); } +function managedVerifyLockPath(env = process.env) { + return trim(env.HOT_DEV_VERIFY_LOCK_FILE) || path.join(repoRootFromEnv(env), 'tmp', 'hot-dev.verify.lock'); +} + function hotDevBrowserURL(env = process.env) { const host = trim(env.FRONTEND_DEV_HOST) || '127.0.0.1'; const port = trim(env.FRONTEND_DEV_PORT) || '5173'; @@ -63,6 +68,32 @@ function statusReportsRunning(output) { return output.includes('[hot-dev-bg] Running'); } +export function managedVerifyLockActive(env = process.env) { + try { + const raw = fs.readFileSync(managedVerifyLockPath(env), 'utf8'); + const ownerPid = Number.parseInt( + raw + .split('\n') + .find((line) => line.startsWith('pid='))?.slice(4) || '', + 10, + ); + if (!Number.isInteger(ownerPid) || ownerPid <= 0) { + return false; + } + process.kill(ownerPid, 0); + return true; + } catch { + return false; + } +} + +export function shouldRestartManagedDevRuntimeForVerification({ + env = process.env, + wasRunning, +}) { + return Boolean(wasRunning) && managedVerifyLockActive(env); +} + async function managedRuntimeStatusOutput(env = process.env) { const status = await runHotDevBg(['status'], env); if (status.code !== 0) { @@ -231,14 +262,21 @@ export async function startManagedDevRuntime({ throw new Error(`hot-dev-bg status failed before start: ${statusBefore.stderr || statusBefore.stdout}`); } const wasRunning = statusReportsRunning(`${statusBefore.stdout}${statusBefore.stderr}`); + const shouldRestartForVerification = shouldRestartManagedDevRuntimeForVerification({ + env, + wasRunning, + }); - const startArgs = ['start']; + const startArgs = [shouldRestartForVerification ? 'restart' : 'start']; if (truthy(env.PULSE_E2E_HOT_DEV_TAKEOVER)) { startArgs.push('--takeover'); } + if (shouldRestartForVerification) { + logger.log('[integration] Restarting managed dev runtime before browser verification to pick up current source changes'); + } const startResult = await runHotDevBg(startArgs, env); if (startResult.code !== 0) { - throw new Error(`hot-dev-bg start failed: ${startResult.stderr || startResult.stdout}`); + throw new Error(`hot-dev-bg ${startArgs[0]} failed: ${startResult.stderr || startResult.stdout}`); } await waitForStableManagedRuntime({ env }); diff --git a/tests/integration/scripts/managed-dev-runtime.test.mjs b/tests/integration/scripts/managed-dev-runtime.test.mjs new file mode 100644 index 000000000..70ade3ce5 --- /dev/null +++ b/tests/integration/scripts/managed-dev-runtime.test.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + managedVerifyLockActive, + shouldRestartManagedDevRuntimeForVerification, +} from './managed-dev-runtime.mjs'; + +test('managedVerifyLockActive returns true for a live verify lock owner', async () => { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pulse-managed-dev-runtime-')); + const lockPath = path.join(rootDir, 'hot-dev.verify.lock'); + await fs.writeFile(lockPath, `pid=${process.pid}\ncreated_at=2026-03-28T23:00:00Z\n`, 'utf8'); + + assert.equal(managedVerifyLockActive({ HOT_DEV_VERIFY_LOCK_FILE: lockPath }), true); +}); + +test('managedVerifyLockActive clears false when the verify lock owner is stale', async () => { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pulse-managed-dev-runtime-')); + const lockPath = path.join(rootDir, 'hot-dev.verify.lock'); + await fs.writeFile(lockPath, 'pid=999999\ncreated_at=2026-03-28T23:00:00Z\n', 'utf8'); + + assert.equal(managedVerifyLockActive({ HOT_DEV_VERIFY_LOCK_FILE: lockPath }), false); +}); + +test('shouldRestartManagedDevRuntimeForVerification only restarts existing sessions under verify lock', async () => { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pulse-managed-dev-runtime-')); + const lockPath = path.join(rootDir, 'hot-dev.verify.lock'); + await fs.writeFile(lockPath, `pid=${process.pid}\ncreated_at=2026-03-28T23:00:00Z\n`, 'utf8'); + + assert.equal( + shouldRestartManagedDevRuntimeForVerification({ + env: { HOT_DEV_VERIFY_LOCK_FILE: lockPath }, + wasRunning: true, + }), + true, + ); + assert.equal( + shouldRestartManagedDevRuntimeForVerification({ + env: { HOT_DEV_VERIFY_LOCK_FILE: lockPath }, + wasRunning: false, + }), + false, + ); +});