From 782118a1ba3644311ca2aa3f938b1e29bb1acc22 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:03:17 +0000 Subject: [PATCH 1/3] fix(scripts): publish-smoke targets the dev server this run started, not the port it asked for (#9647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/publish-smoke.sh` picked its dev-server port from a fixed default (3210) and derived BASE_URL from it. Agent dispatch containers run several agents against one filesystem and one network namespace, so that default was shared state between concurrent runs. Measured with a neighbour holding the requested port: $ objectstack dev --port 34217 --fresh ↪ server bound to port 34218 (requested 34217) $ curl http://localhost:34217/api/v1/health → 200, the neighbour's body $ curl http://localhost:34218/api/v1/health → 200, ours `objectstack dev` auto-shifts (serve.ts gates it on `flags.dev`, and `dev` always spawns `serve --dev`). So run B's app came up on the neighbour port while run B's wait loop and BASE_URL still named the requested one, and every auth and CRUD probe ran against run A's app. The sibling fix in gen-sdui-manifest.sh does not transfer. There is no `--strictPort` equivalent a caller can opt into, and a liveness check on our own spawn was ALREADY in this wait loop — it passes throughout the measurement above, because our server did not die, it succeeded somewhere else. So the script reads the port its own server actually bound, from the runtime state file serve.ts publishes under OS_HOME for external supervisors, in an OS_HOME this run can prove is its own because it pins the dev child's TMPDIR. A per-run free port ships alongside it to make the shift rare; an explicit SMOKE_PORT is still passed through exactly, with no search around it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja --- .../publish-smoke-port-collision.test.ts | 235 ++++++++++++++++++ scripts/publish-smoke.sh | 212 ++++++++++++++-- 2 files changed, 429 insertions(+), 18 deletions(-) create mode 100644 packages/spec/scripts/publish-smoke-port-collision.test.ts diff --git a/packages/spec/scripts/publish-smoke-port-collision.test.ts b/packages/spec/scripts/publish-smoke-port-collision.test.ts new file mode 100644 index 0000000000..73cf32fc64 --- /dev/null +++ b/packages/spec/scripts/publish-smoke-port-collision.test.ts @@ -0,0 +1,235 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Pins the CONCURRENT-RUN contract of `scripts/publish-smoke.sh` — the half that +// decides whether the app this gate smoke-tests is the one this run built. +// +// ## What was measured +// +// Agent dispatch containers run several agents against one filesystem and one +// network namespace, so the script's fixed default port (3210) was shared state +// between overlapping runs. `objectstack dev` AUTO-SHIFTS off a busy port — +// `packages/cli/src/commands/serve.ts` gates that on `flags.dev`, and `dev` +// always spawns `serve --dev`. Measured with a neighbour holding 34217: +// +// $ objectstack dev --port 34217 --fresh +// ↪ server bound to port 34218 (requested 34217) +// $ curl http://localhost:34217/api/v1/health → 200, the NEIGHBOUR's body +// $ curl http://localhost:34218/api/v1/health → 200, ours +// +// So run B's app came up on the neighbour port while run B's wait loop and +// BASE_URL still named 34217: run B ran every auth and CRUD probe against run +// A's app. +// +// ## Why the sibling fix does not transfer, which is what this test exists for +// +// `scripts/gen-sdui-manifest.sh` (the same defect, one file over) shipped +// `--strictPort` plus a probe requiring the session that run spawned to be +// alive. Neither half transfers: +// +// * There is no `--strictPort` here. `dev` always passes `--dev` to `serve`, +// so the auto-shift cannot be declined by a caller, and giving the CLI a +// flag to decline it is a CLI contract change, not a fix to this script. +// * A liveness check on our own spawn was ALREADY in this wait loop, and it +// passes throughout the measurement above — our server did not die, it +// succeeded on another port. Liveness is not the question here. +// +// What the script does instead is read the port its own server actually bound, +// from the runtime state file `serve.ts` publishes under OS_HOME expressly for +// external supervisors, in an OS_HOME this run can prove is its own because it +// pinned the dev child's TMPDIR. That is the contract pinned below. +// +// ## Why these are executed assertions and not greps +// +// A grep for `TMPDIR` passes against a file that names it only in a comment, and +// a grep for "reads the runtime file" passes against a check that runs in the +// wrong order. So the argv is asserted through `smoke_dev_server_argv`, the +// function the script itself builds its argv from, and the retarget is asserted +// by standing up a real neighbour on the requested port and watching the real +// wait function decline it in favour of the port its own state file names. +// +// The vacuity guards matter as much as the assertions. `NEIGHBOUR_BODY` proves +// the neighbour was genuinely reachable at the same spelling the old wait loop +// probed, and `OURS_BODY` proves the retargeted port was genuinely a different +// server. Without those a green "retargeted" could mean nothing was listening +// and nothing was turned down. +// +// No `objectstack dev` boot and no scaffold: the contract under test belongs to +// the shell script, and the ports are picked at run time by the script's own +// helper so this test cannot collide with a concurrent agent — which would be a +// poor look here. + +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const SCRIPT = path.resolve(HERE, '..', '..', '..', 'scripts', 'publish-smoke.sh'); + +function have(bin: string): boolean { + try { + execFileSync('sh', ['-c', `command -v ${bin}`], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +// Linux-only by construction, like the sibling collision test: the failure being +// pinned is an agent-container one, and the helpers read `/proc`-backed liveness. +const RUNNABLE = process.platform === 'linux' && ['bash', 'curl', 'jq', 'node'].every(have); + +/** A tiny HTTP server on $STUB_PORT announcing $STUB_NAME, as a `node -e` program. */ +const HTTP_STUB = [ + 'const http = require("node:http");', + 'http.createServer((_q, r) => {', + ' r.writeHead(200, { "content-type": "application/json" });', + ' r.end(JSON.stringify({ iam: process.env.STUB_NAME }));', + '}).listen(Number(process.env.STUB_PORT));', +].join(''); + +/** + * Run a bash harness that SOURCES the real script (so the real functions run) + * and prints `KEY=value` lines. Returns them parsed. + */ +function runHarness(body: string[]): Record { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'publish-smoke-collision-')); + const harness = path.join(dir, 'harness.sh'); + fs.writeFileSync( + harness, + [ + '#!/usr/bin/env bash', + 'set -u', + `export SMOKE_ROOT=${JSON.stringify(dir)}`, + 'export SMOKE_KEEP=1', + `export STUB=${JSON.stringify(HTTP_STUB)}`, + // Sourcing defines the helpers and runs nothing. + `source ${JSON.stringify(SCRIPT)}`, + // The script's own `set -euo pipefail` came with it. Several steps below + // are EXPECTED to fail and their exit codes are the thing being reported, + // so hand errexit back — without this the harness dies at the first + // expected failure, before it can kill its stubs, and the run hangs on a + // stdout pipe held open by an orphan rather than failing an assertion. + 'set +e +o pipefail', + 'echo "SOURCED=ok"', + ...body, + ].join('\n'), + { mode: 0o755 }, + ); + + const out = execFileSync('bash', [harness], { encoding: 'utf8', timeout: 120_000 }); + const parsed: Record = {}; + for (const line of out.split('\n')) { + const m = /^([A-Z_]+)=(.*)$/.exec(line); + if (m) parsed[m[1]] = m[2]; + } + return parsed; +} + +describe.skipIf(!RUNNABLE)('[#9647] publish-smoke.sh smoke-tests its OWN dev server', () => { + it('sources cleanly and defines the collision helpers without running the gate', () => { + const r = runHarness([ + 'echo "PICK_FN=$(type -t smoke_pick_free_port)"', + 'echo "WAIT_FN=$(type -t smoke_wait_for_own_server)"', + 'echo "ARGV_FN=$(type -t smoke_dev_server_argv)"', + // The gate itself must NOT have run: no scaffold, no tarballs. + 'echo "SCAFFOLDED=$([ -d "$SMOKE_ROOT/smoke-app" ] && echo yes || echo no)"', + ]); + expect(r.SOURCED).toBe('ok'); + expect(r.PICK_FN).toBe('function'); + expect(r.WAIT_FN).toBe('function'); + expect(r.ARGV_FN).toBe('function'); + expect(r.SCAFFOLDED).toBe('no'); + }); + + it('picks a per-run port and skips one that is already held', () => { + const r = runHarness([ + 'FIRST="$(smoke_pick_free_port 3210)"', + 'echo "FIRST=$FIRST"', + // Hold it, then ask again from the same base. + 'STUB_PORT="$FIRST" STUB_NAME=holder node -e "$STUB" >/dev/null 2>&1 & HOLDER=$!', + 'sleep 1', + 'SECOND="$(smoke_pick_free_port 3210)"', + 'echo "SECOND=$SECOND"', + 'echo "HOLDER_REACHABLE=$(curl -sS "http://localhost:$FIRST/" | jq -r .iam)"', + 'kill "$HOLDER" 2>/dev/null', + ]); + expect(r.FIRST).toMatch(/^\d+$/); + expect(r.SECOND).toMatch(/^\d+$/); + // Vacuity guard: the port really was held, so the skip really was a skip. + expect(r.HOLDER_REACHABLE).toBe('holder'); + expect(r.SECOND).not.toBe(r.FIRST); + }); + + it('pins the dev child TMPDIR in the argv it actually spawns', () => { + const r = runHarness([ + 'DEV_TMPDIR="$SMOKE_ROOT/dev-tmp"', + 'SMOKE_PORT=31234', + 'echo "ARGV=$(smoke_dev_server_argv | tr "\\n" " ")"', + ]); + // `env` and the assignment are part of the invocation, not a comment about it. + expect(r.ARGV).toContain('env NO_COLOR=1 '); + expect(r.ARGV).toContain('TMPDIR='); + expect(r.ARGV).toContain('dev-tmp'); + expect(r.ARGV).toContain('objectstack dev --port 31234 --fresh'); + }); + + it("declines a neighbour on the requested port and targets the port its OWN state file names", () => { + const r = runHarness([ + 'DEV_TMPDIR="$SMOKE_ROOT/dev-tmp"', + 'mkdir -p "$DEV_TMPDIR/objectstack-dev-XXXX"', + 'REQUESTED="$(smoke_pick_free_port 3210)"', + 'OURS="$(smoke_pick_free_port $((REQUESTED + 50)))"', + 'echo "REQUESTED=$REQUESTED"', + 'echo "OURS=$OURS"', + // Run A: the neighbour, answering 200 on the port run B asked for. This is + // exactly what the pre-fix wait loop accepted. + 'STUB_PORT="$REQUESTED" STUB_NAME=NEIGHBOUR node -e "$STUB" >/dev/null 2>&1 & NEIGHBOUR=$!', + // Run B: our own server, on the port the auto-shift moved us to, plus the + // runtime state file `serve.ts` writes under our pinned OS_HOME. + 'STUB_PORT="$OURS" STUB_NAME=OURS node -e "$STUB" >/dev/null 2>&1 & SERVER_PID=$!', + 'sleep 1', + 'printf \'{"pid":%s,"port":%s,"url":"http://localhost:%s","environmentId":"env_local"}\' \\', + ' "$SERVER_PID" "$OURS" "$OURS" > "$DEV_TMPDIR/objectstack-dev-XXXX/runtime.env_local.json"', + // Vacuity guards: both servers genuinely reachable, at the spelling probed. + 'echo "NEIGHBOUR_BODY=$(curl -sS "http://localhost:$REQUESTED/api/v1/health" | jq -r .iam)"', + 'echo "OURS_BODY=$(curl -sS "http://localhost:$OURS/api/v1/health" | jq -r .iam)"', + 'smoke_wait_for_own_server 10', + 'echo "BOUND_PORT=$BOUND_PORT"', + 'kill "$NEIGHBOUR" "$SERVER_PID" 2>/dev/null', + ]); + // Both were up, so the choice below was a real choice. + expect(r.NEIGHBOUR_BODY).toBe('NEIGHBOUR'); + expect(r.OURS_BODY).toBe('OURS'); + expect(r.REQUESTED).not.toBe(r.OURS); + // The whole card: the requested port answered 200 and was turned down anyway. + expect(r.BOUND_PORT).toBe(r.OURS); + expect(r.BOUND_PORT).not.toBe(r.REQUESTED); + }); + + it('refuses rather than guessing when no runtime state file is published', () => { + const r = runHarness([ + 'DEV_TMPDIR="$SMOKE_ROOT/dev-tmp"', + 'mkdir -p "$DEV_TMPDIR"', + 'REQUESTED="$(smoke_pick_free_port 3210)"', + // A neighbour answering on the requested port, and a live process of our + // own that never published where it bound. + 'STUB_PORT="$REQUESTED" STUB_NAME=NEIGHBOUR node -e "$STUB" >/dev/null 2>&1 & NEIGHBOUR=$!', + 'sleep 30 >/dev/null 2>&1 & SERVER_PID=$!', + 'sleep 1', + 'echo "NEIGHBOUR_BODY=$(curl -sS "http://localhost:$REQUESTED/api/v1/health" | jq -r .iam)"', + 'echo "OUR_LEADER_ALIVE=$(kill -0 "$SERVER_PID" 2>/dev/null && echo yes || echo no)"', + 'OUT="$(smoke_wait_for_own_server 1 2>&1)"; echo "WAIT_RC=$?"', + 'echo "WAIT_SAID_STATE_FILE=$(printf %s "$OUT" | grep -c "runtime state file")"', + 'kill "$NEIGHBOUR" "$SERVER_PID" 2>/dev/null', + ]); + // Vacuity guards: a 200 WAS available on the requested port, and our own + // process WAS alive — the two facts the pre-fix loop accepted as sufficient. + expect(r.NEIGHBOUR_BODY).toBe('NEIGHBOUR'); + expect(r.OUR_LEADER_ALIVE).toBe('yes'); + expect(r.WAIT_RC).not.toBe('0'); + expect(r.WAIT_SAID_STATE_FILE).toBe('1'); + }); +}); diff --git a/scripts/publish-smoke.sh b/scripts/publish-smoke.sh index e340e3e0e6..a071b895c5 100644 --- a/scripts/publish-smoke.sh +++ b/scripts/publish-smoke.sh @@ -59,12 +59,14 @@ # SMOKE_MODE pack | registry (default: pack) # SMOKE_ROOT work dir (default: mktemp -d) # SMOKE_KEEP 1 = keep work dir + logs (default: 0, auto-clean) -# SMOKE_PORT dev-server port (default: 3210) +# SMOKE_PORT dev-server port (default: a free port per run) set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SMOKE_MODE="${SMOKE_MODE:-pack}" -SMOKE_PORT="${SMOKE_PORT:-3210}" +# Empty = pick a free port per run, just before the boot. A caller who names a +# port gets exactly that port and no search — see the port block in section 2. +SMOKE_PORT="${SMOKE_PORT:-}" SMOKE_KEEP="${SMOKE_KEEP:-0}" SMOKE_ROOT="${SMOKE_ROOT:-$(mktemp -d "${TMPDIR:-/tmp}/objectstack-publish-smoke.XXXXXX")}" APP_NAME="smoke-app" @@ -73,11 +75,20 @@ APP_DIR="$SMOKE_ROOT/$APP_NAME" # scaffolder rewrites AWAY from. APP_NAME must not derive to it — see the # identity assertion in section 1b, which is what keeps that true on purpose. TEMPLATE_NAMESPACE="blank" -# localhost, not 127.0.0.1: the auth plugin's default trustedOrigins is a -# localhost wildcard, so a 127.0.0.1 origin draws a 403 INVALID_ORIGIN. -BASE_URL="http://localhost:$SMOKE_PORT" SERVER_LOG="$SMOKE_ROOT/server.log" SERVER_PID="" +# TMPDIR for the dev child ALONE. `--fresh` puts its ephemeral OS_HOME under the +# child's own `os.tmpdir()`, and the serve process publishes the port it really +# bound into a runtime state file there. Pinning that tmpdir to a directory this +# run created is what makes the file we read provably ours rather than a +# neighbouring run's — see smoke_wait_for_own_server. +DEV_TMPDIR="$SMOKE_ROOT/dev-tmp" +# Assigned only after the server reports the port it ACTUALLY bound; the port we +# request is a request, not a fact. localhost, not 127.0.0.1: the auth plugin's +# default trustedOrigins is a localhost wildcard, so a 127.0.0.1 origin draws a +# 403 INVALID_ORIGIN. +BASE_URL="" +BOUND_PORT="" log() { printf '\n== %s\n' "$*"; } fail() { printf '::error::%s\n' "$*" >&2; exit 1; } @@ -112,6 +123,152 @@ cleanup() { fi exit "$code" } +# ── collision safety between CONCURRENT RUNS ──────────────────────────── +# +# Agent dispatch containers run several agents against one filesystem and one +# network namespace, so a fixed port is shared state between overlapping runs. +# The work dir and the server log above are already mktemp-qualified; the port +# was the one piece of per-run state still spelled as a literal. +# +# What makes the port worse than a shared log here is that `objectstack dev` +# AUTO-SHIFTS off a busy one. Measured, with a neighbour already holding 34217: +# +# $ objectstack dev --port 34217 --fresh +# ↪ server bound to port 34218 (requested 34217) +# $ curl http://localhost:34217/api/v1/health +# {"iam":"NEIGHBOUR-RUN-A", ...} ← HTTP 200, someone else +# $ curl http://localhost:34218/api/v1/health +# {"success":true,"data":{"status":"ok", ...}} ← HTTP 200, ours +# +# packages/cli/src/commands/serve.ts gates that shift on `flags.dev`, which is +# exactly the path `objectstack dev` takes, so this script always gets it. The +# non-dev branch beside it refuses loudly instead — but opting into that refusal +# is not available to a caller (`dev` always spawns `serve --dev`), and changing +# the CLI to offer it is a contract change, not a fix for this script. +# +# The obvious countermeasure does NOT work, and that is the part worth spelling +# out. A liveness check on our own spawn — `kill -0 "$SERVER_PID"`, which this +# wait loop ALREADY had — answers "yes" throughout the measurement above: our +# server did not fail, it succeeded somewhere else. Picking a free port per run +# does not close it either, because the pick reserves nothing and the shift can +# still move us after it. So neither half of the sibling fix in +# scripts/gen-sdui-manifest.sh transfers unchanged: vite could be told +# `--strictPort`, and its wait loop had no liveness check to begin with. +# +# What does work is reading the port our own server actually bound. serve.ts +# publishes it ("Publish the actually-bound port": pid + port + url, written to +# a runtime state file under OS_HOME expressly so external supervisors never +# have to guess), `--fresh` puts that OS_HOME under the dev child's own tmpdir, +# and we pin that tmpdir to a directory this run created. So the file we read +# can only describe our own server, and BASE_URL is derived from where our app +# IS rather than from where we asked it to be. +# +# Rejected, with the measurement that rejected it: asserting on something unique +# to this run's scaffold instead of on the server we spawned. It would be the +# better assertion — it is what the smoke actually cares about — but there is no +# anonymous endpoint that carries the app's identity, so it cannot gate the +# readiness wait, which is the point where the wrong app has to be turned down. +# Measured against a booted app: `GET /api/v1/data/` and +# `GET /api/v1/data/no_such_object_zzz` both answer 401 UNAUTHENTICATED (the auth +# gate runs before routing), and `GET /api/v1/discovery` — which is anonymous — +# reports the constant `"name":"ObjectStack API"`, not the project. The first +# app-specific assertion available is the authenticated CRUD probe in section 3, +# which lands after the auth probes have already run against the wrong app. + +# Print a TCP port that is free right now, searching upward from $1. +# +# ADVISORY ONLY. It reserves nothing, and `objectstack dev` binds a moment later, +# so a concurrent run can still take the port in between. Closing that race is +# not this helper's job — smoke_wait_for_own_server turns losing it into a +# warning and a correctly retargeted BASE_URL instead of a silent wrong answer. +# This only has to make losing it rare. +# +# Probes the wildcard address with no host argument, the same spelling +# serve.ts's own isPortAvailable() uses, so this sees a busy port exactly when +# the CLI would. +smoke_pick_free_port() { + node - "${1:-3210}" "${2:-200}" <<'SMOKE_PICK_FREE_PORT' +const net = require('node:net'); +const base = Number(process.argv[2]); +const span = Number(process.argv[3]); +const isFree = (port) => + new Promise((resolve) => { + const probe = net.createServer(); + probe.once('error', () => resolve(false)); + probe.once('listening', () => probe.close(() => resolve(true))); + probe.listen(port); + }); +(async () => { + for (let port = base; port < base + span; port += 1) { + if (await isFree(port)) { + process.stdout.write(String(port)); + return; + } + } + process.stderr.write(`no free TCP port in [${base}, ${base + span})\n`); + process.exitCode = 1; +})(); +SMOKE_PICK_FREE_PORT +} + +# The argv the dev server runs under, one word per line — `env` and its +# assignments included, because the TMPDIR pin is part of the invocation and not +# a detail of it. +# +# A function rather than an inline command line so that pin is assertable by +# SOURCING this file, instead of only by grepping it. A grep assertion would also +# pass against a version that names TMPDIR in a comment and nowhere else, which +# is precisely the regression this guards. +smoke_dev_server_argv() { + printf '%s\n' env NO_COLOR=1 "TMPDIR=$DEV_TMPDIR" \ + ./node_modules/.bin/objectstack dev --port "$SMOKE_PORT" --fresh +} + +# Wait until the dev server THIS RUN started is healthy, and set BOUND_PORT to +# the port it is actually answering on. $1 = probe count (2s apart). +# +# Ordering is load-bearing. The runtime state file is read BEFORE anything is +# curled, because a neighbouring run's server answers 200 on the requested port +# exactly like ours would — "the port answers" is no evidence at all. And the +# liveness check is repeated AFTER a successful curl, because ours can exit +# between the read and the probe and leave the port to whoever grabs it next. +smoke_wait_for_own_server() { + local timeout="$1" i f runtime_file bound saw_runtime=0 + for i in $(seq 1 "$timeout"); do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + fail "dev server exited before becoming healthy" + fi + runtime_file="" + for f in "$DEV_TMPDIR"/objectstack-dev-*/runtime.*.json; do + if [ -f "$f" ]; then runtime_file="$f"; break; fi + done + if [ -n "$runtime_file" ]; then + saw_runtime=1 + bound=$(jq -er '.port | numbers' "$runtime_file" 2>/dev/null || true) + if [ -n "$bound" ] && curl -fsS "http://localhost:$bound/api/v1/health" >/dev/null 2>&1; then + kill -0 "$SERVER_PID" 2>/dev/null \ + || fail "dev server exited while its port was being probed" + BOUND_PORT="$bound" + echo " healthy after probe #$i — our server is on port $BOUND_PORT" + return 0 + fi + fi + sleep 2 + done + if [ "$saw_runtime" = "0" ]; then + fail "dev server never published a runtime state file under $DEV_TMPDIR (expected objectstack-dev-*/runtime.*.json, written by packages/cli/src/commands/serve.ts). Without it this script cannot tell its own server from a concurrent run's on the same port, and refuses to guess." + fi + fail "dev server published port $bound but never answered there within $((timeout * 2))s" +} + +# Sourcing this file defines the helpers above and runs nothing. `${BASH_SOURCE[0]}` +# differs from `$0` exactly when the file is sourced, which is how the collision +# test drives the real functions instead of grepping for them — a grep passes +# against a version that only mentions the behaviour in a comment. +if [ "${BASH_SOURCE[0]}" != "$0" ]; then + return 0 +fi + trap cleanup EXIT # ── 1. obtain the project ─────────────────────────────────────────────────── @@ -295,25 +452,44 @@ echo " ok — built dist/objectstack.json ($(wc -c < "$APP_DIR/dist/objectstack # ── 2. boot the dev server ────────────────────────────────────────────────── # --fresh: ephemeral OS_HOME + sqlite DB + seeded admin # (admin@objectos.ai / admin123) — no first-run wizard to block on. -log "Starting objectstack dev (port $SMOKE_PORT)" +# The port, resolved as late as possible so the free-port probe and the bind it +# informs are as close together as they can be. An explicit SMOKE_PORT is passed +# through UNCHANGED and never searched around: a caller who names a port is +# making a request this script has no business quietly re-deciding. The CLI can +# still shift off it — the warning below says so out loud when it does. +if [ -n "$SMOKE_PORT" ]; then + echo " SMOKE_PORT=$SMOKE_PORT — using it exactly, no per-run search" +else + SMOKE_PORT="$(smoke_pick_free_port 3210 || true)" + case "$SMOKE_PORT" in + '' | *[!0-9]*) fail "could not find a free TCP port for this run's dev server" ;; + esac +fi + +log "Starting objectstack dev (requested port $SMOKE_PORT)" +mkdir -p "$DEV_TMPDIR" # NO_COLOR: some loggers colorize even without a TTY; ANSI codes around # "ERROR" would slip through the log scan below (they did — see the escaped # `\x1b[31m…ERROR…` line the negative test produced). -(cd "$APP_DIR" && exec env NO_COLOR=1 ./node_modules/.bin/objectstack dev --port "$SMOKE_PORT" --fresh) \ +# TMPDIR: see the collision-safety block — it is what makes the runtime state +# file this run reads back provably its own. +mapfile -t DEV_ARGV < <(smoke_dev_server_argv) +(cd "$APP_DIR" && exec "${DEV_ARGV[@]}") \ > "$SERVER_LOG" 2>&1 & SERVER_PID=$! -for i in $(seq 1 60); do - if curl -fsS "$BASE_URL/api/v1/health" >/dev/null 2>&1; then - break - fi - if ! kill -0 "$SERVER_PID" 2>/dev/null; then - fail "dev server exited before becoming healthy" - fi - [ "$i" = 60 ] && fail "dev server not healthy after 120s" - sleep 2 -done -echo " healthy after probe #$i" +smoke_wait_for_own_server 60 + +if [ "$BOUND_PORT" != "$SMOKE_PORT" ]; then + # Not fatal: our app is up and healthy, it is simply not where we asked. Every + # probe below now targets it correctly, which is the whole point. What IS + # newsworthy is the neighbour — before this diagnostic existed, the run went on + # to smoke-test whatever was holding the requested port and reported on it. + printf '\n⚠ objectstack dev auto-shifted: requested %s, bound %s.\n' "$SMOKE_PORT" "$BOUND_PORT" + printf ' Something else is holding %s — most likely a concurrent publish-smoke run.\n' "$SMOKE_PORT" + printf ' Smoking THIS run'"'"'s server on %s; whatever answers on %s is not ours.\n' "$BOUND_PORT" "$SMOKE_PORT" +fi +BASE_URL="http://localhost:$BOUND_PORT" # ── 3. probes ─────────────────────────────────────────────────────────────── COOKIES_USER="$SMOKE_ROOT/cookies-user.txt" From 28b3162fadad1bc5321f2f70817ee97535fa9f1b Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:17:28 +0000 Subject: [PATCH 2/3] chore(ci): declare serve.ts as a spec test input for the publish-smoke collision test (#9647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-cross-package-test-inputs flags the new test: it names packages/cli/src/commands/serve.ts, and no declared glob covered it. Declared rather than reworded, following the three precedents recorded in the gate itself (check-nul-bytes.mjs, sync-template-versions.mjs, the realtime protocol page): the literal collector takes quoted paths without parsing, so a mention forces a declaration, and declaring the file is cheaper than rewording prose to dodge a scanner. One file, not the commands tree — the test reads publish-smoke.sh and nothing else. turbo.json mirrors the glob, which the gate also verifies. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja --- scripts/check-cross-package-test-inputs.mjs | 11 +++++++++++ turbo.json | 1 + 2 files changed, 12 insertions(+) diff --git a/scripts/check-cross-package-test-inputs.mjs b/scripts/check-cross-package-test-inputs.mjs index 1483bc79eb..19a0e0ecc2 100644 --- a/scripts/check-cross-package-test-inputs.mjs +++ b/scripts/check-cross-package-test-inputs.mjs @@ -96,6 +96,17 @@ const CROSS_PACKAGE_TEST_INPUTS = { 'content/docs/references/**', // scripts/dist-freshness.test.ts stages a fixture around the root scripts dir 'scripts/**', + // `serve.ts` is named in a comment rather than read, the same shape as + // `check-nul-bytes.mjs` / `sync-template-versions.mjs` / the realtime + // protocol page below, and settled the same way: the literal collector + // takes quoted paths without parsing, so a mention forces a declaration, + // and declaring the file is cheaper than rewording prose to dodge the + // scanner. scripts/publish-smoke-port-collision.test.ts cites it for the + // measurement that justifies its whole existence — `serve.ts` auto-shifts + // off a busy port whenever `flags.dev` is set, which is the only reason + // publish-smoke.sh cannot trust the port it asked for. One file, not the + // commands tree: the test reads publish-smoke.sh and nothing else. + 'packages/cli/src/commands/serve.ts', // scripts/liveness/evidence.test.ts resolves the evidence paths the // liveness ledgers cite, so those files' existence is a spec input. 'packages/runtime/src/**', diff --git a/turbo.json b/turbo.json index 445e5a1fb7..ca8e333e67 100644 --- a/turbo.json +++ b/turbo.json @@ -34,6 +34,7 @@ "$TURBO_ROOT$/packages/lint/src/**", "$TURBO_ROOT$/content/docs/references/**", "$TURBO_ROOT$/scripts/**", + "$TURBO_ROOT$/packages/cli/src/commands/serve.ts", "$TURBO_ROOT$/packages/runtime/src/**", "$TURBO_ROOT$/packages/objectql/src/validation/**", "$TURBO_ROOT$/packages/metadata-protocol/src/**", From c53db02949ac6cd2285e17af26d4434f106b9158 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:24:21 +0000 Subject: [PATCH 3/3] test(scripts): the collision harness must not leak listeners when a step exits early (#9647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observed after a run: two stubs orphaned on low ports, holding them in a container several agents share — the exact collision this file is about. Cause: `fail` inside the sourced script calls `exit` directly, so a harness step that is EXPECTED to fail leaves before its explicit `kill` line. An EXIT trap over `jobs -p` covers that path and every other way the harness can leave. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja --- packages/spec/scripts/publish-smoke-port-collision.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/spec/scripts/publish-smoke-port-collision.test.ts b/packages/spec/scripts/publish-smoke-port-collision.test.ts index 73cf32fc64..f2b9d21a25 100644 --- a/packages/spec/scripts/publish-smoke-port-collision.test.ts +++ b/packages/spec/scripts/publish-smoke-port-collision.test.ts @@ -113,6 +113,11 @@ function runHarness(body: string[]): Record { // expected failure, before it can kill its stubs, and the run hangs on a // stdout pipe held open by an orphan rather than failing an assertion. 'set +e +o pipefail', + // Belt and braces on top of that: every stub below is a listener, and a + // leaked one holds a low port in a container several agents share — the + // very collision this file is about. `jobs -p` on EXIT kills them however + // the harness leaves, including paths no explicit `kill` line reaches. + 'trap \'for j in $(jobs -p); do kill "$j" 2>/dev/null; done\' EXIT', 'echo "SOURCED=ok"', ...body, ].join('\n'),