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..f2b9d21a25 --- /dev/null +++ b/packages/spec/scripts/publish-smoke-port-collision.test.ts @@ -0,0 +1,240 @@ +// 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', + // 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'), + { 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/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/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" 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/**",