Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions scripts/ci/dev-up.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,40 @@ async function installPowerShellStubs(fakeBin) {
)
}

async function installPowerShellNetstatFallbackProbe(fixture) {
const launcherPath = join(fixture.scriptsDir, 'dev-up.ps1')
const source = normalise(await readFile(launcherPath, 'utf8'))
const marker = 'Set-StrictMode -Version Latest'
const netstatPath = join(fixture.fakeBin, 'netstat.cmd')
const probe = String.raw`function Get-NetTCPConnection {
[CmdletBinding()]
param([string]$State, [int]$LocalPort)
throw [System.Management.Automation.CommandNotFoundException]::new("synthetic unavailable")
}
`
assert.equal(source.split(marker).length - 1, 1, 'unexpected strict-mode marker count')
assert.equal(source.includes('$netstat = Join-Path $env:SystemRoot "System32/netstat.exe"'), true)
const instrumented = source
.replace(marker, `${marker}\n${probe}`)
.replace(
'$netstat = Join-Path $env:SystemRoot "System32/netstat.exe"',
'$netstat = $env:TASKDECK_TEST_NETSTAT_PATH',
)
assert.notEqual(instrumented, source, 'PowerShell netstat probe did not instrument the launcher')
await writeFile(launcherPath, instrumented)
await writeFile(
netstatPath,
[
'@echo off',
'echo TCP 0.0.0.0:%TASKDECK_TEST_NETSTAT_PORT% 0.0.0.0:0 ABHOEREN 4242',
'echo TCP 127.0.0.1:%TASKDECK_TEST_NETSTAT_PORT% 127.0.0.1:51234 WARTEND 0',
'exit /b 0',
'',
].join('\r\n'),
)
return netstatPath
}

async function installBashStubs(fakeBin) {
const stubs = {
node: String.raw`#!/usr/bin/env bash
Expand Down Expand Up @@ -1244,6 +1278,130 @@ if (powershell) {
}

if (bash) {
test('Bash: localized netstat state still identifies a listening socket', { concurrency: false }, async () => {
const source = normalise(await readFile(bashLauncher, 'utf8'))
const fixtureRoot = await mkdtemp(join(tmpdir(), 'taskdeck-dev-up-netstat-seam-'))
const fakeBin = join(fixtureRoot, 'bin')
const fakeNetstat = join(fakeBin, 'netstat')
const harness = join(fixtureRoot, 'netstat-seam.sh')
try {
await mkdir(fakeBin, { recursive: true })
await writeFile(
fakeNetstat,
String.raw`#!/usr/bin/env bash
printf '%s\n' 'Proto Recv-Q Send-Q Local Address Foreign Address State'
printf 'tcp 0 0 127.0.0.1:%s 0.0.0.0:* ABHOEREN\n' "$TASKDECK_TEST_NETSTAT_PORT"
printf 'tcp 0 0 127.0.0.1:%s 127.0.0.1:51234 WARTEND\n' "$TASKDECK_TEST_NETSTAT_PORT"
printf 'tcp 0 0 127.0.0.1:%s 127.0.0.1:51235 WARTEND\n' "$TASKDECK_TEST_NETSTAT_DRAINED_PORT"
`,
)
await chmod(fakeNetstat, 0o755)
await writeFile(
harness,
String.raw`#!/usr/bin/env bash
set -euo pipefail
command() {
if [[ "$#" -eq 2 && "$1" == '-v' && ("$2" == 'ss' || "$2" == 'lsof') ]]; then return 1; fi
builtin command "$@"
}
${extractBashFunction(source, 'port_listener_inventory')}
[[ "$(port_listener_inventory "$TASKDECK_TEST_NETSTAT_PORT")" == 'listening' ]]
# A port whose only rows are drained connections with a real peer is free, whatever the state token says.
[[ "$(port_listener_inventory "$TASKDECK_TEST_NETSTAT_DRAINED_PORT")" == 'free' ]]
`,
)
const result = spawnSync(bash, [toPosixPath(harness)], {
encoding: 'utf8',
timeout: 5000,
windowsHide: true,
env: {
...process.env,
PATH: `${toPosixPath(fakeBin)}:/usr/local/bin:/usr/bin:/bin`,
TASKDECK_TEST_NETSTAT_PORT: '43210',
TASKDECK_TEST_NETSTAT_DRAINED_PORT: '43211',
},
})
assert.ifError(result.error)
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`)
} finally {
await removeDirectory(fixtureRoot)
}
})

test('Bash: timeout parsing matches PowerShell for whitespace and signed zero', { concurrency: false }, async () => {
const source = normalise(await readFile(bashLauncher, 'utf8'))
const fixtureRoot = await mkdtemp(join(tmpdir(), 'taskdeck-dev-up-timeout-seam-'))
const harness = join(fixtureRoot, 'timeout-seam.sh')
try {
await writeFile(
harness,
String.raw`#!/usr/bin/env bash
set -euo pipefail
warn() { :; }
DEFAULT_PORT_RELEASE_TIMEOUT_MS=30000
${extractBashFunction(source, 'port_release_timeout_ms')}
assert_value() {
local input="$1" expected="$2"
TASKDECK_DEV_UP_PORT_RELEASE_TIMEOUT_MS="$input"
[[ "$(port_release_timeout_ms)" == "$expected" ]]
}
assert_value ' 42 ' 42
assert_value '-0' 0
assert_value ' +0 ' 0
assert_value ' ' 30000
`,
)
const result = spawnSync(bash, [toPosixPath(harness)], {
encoding: 'utf8',
timeout: 5000,
windowsHide: true,
env: { ...process.env },
})
assert.ifError(result.error)
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`)
} finally {
await removeDirectory(fixtureRoot)
}
})

test('Bash: now_ms rejects a bare-seconds date result', { concurrency: false }, async () => {
const source = normalise(await readFile(bashLauncher, 'utf8'))
const fixtureRoot = await mkdtemp(join(tmpdir(), 'taskdeck-dev-up-clock-seam-'))
const harness = join(fixtureRoot, 'clock-seam.sh')
try {
await writeFile(
harness,
String.raw`#!/usr/bin/env bash
set -euo pipefail
DATE_MODE=seconds
date() {
case "$1" in
+%s%3N)
if [[ "$DATE_MODE" == 'seconds' ]]; then printf '1700000000\n'; else printf '1700000000123\n'; fi
;;
+%s) printf '1700000000\n' ;;
*) return 1 ;;
esac
}
${extractBashFunction(source, 'now_ms')}
[[ "$(now_ms)" == '1700000000000' ]]
DATE_MODE=milliseconds
[[ "$(now_ms)" == '1700000000123' ]]
`,
)
const result = spawnSync(bash, [toPosixPath(harness)], {
encoding: 'utf8',
timeout: 5000,
windowsHide: true,
env: { ...process.env },
})
assert.ifError(result.error)
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`)
} finally {
await removeDirectory(fixtureRoot)
}
})

test('Bash: Match to Mismatch after TERM never escalates to KILL', { concurrency: false }, async () => {
const source = normalise(await readFile(bashLauncher, 'utf8'))
const fixtureRoot = await mkdtemp(join(tmpdir(), 'taskdeck-dev-up-identity-seam-'))
Expand Down Expand Up @@ -1925,6 +2083,40 @@ for (const platform of platforms) {
},
)

if (platform.name === 'PowerShell') {
test(
`${platform.name}: a localized netstat state still identifies a live listener`,
{ concurrency: false },
async () => {
const fixture = await createFixture(platform)
const apiPort = await getFreePort()
const foreign = await listenForeign('127.0.0.1')
const frontendPort = foreign.address().port
try {
const netstatPath = await installPowerShellNetstatFallbackProbe(fixture)
await writeReapedState(fixture, { apiPort, frontendPort })
const result = runLauncher(platform, fixture, {
stop: true,
timeout: 30_000,
env: {
TASKDECK_DEV_UP_PORT_RELEASE_TIMEOUT_MS: '1500',
TASKDECK_TEST_NETSTAT_PATH: netstatPath,
TASKDECK_TEST_NETSTAT_PORT: String(frontendPort),
},
})
assertFailedClosed(result)
assert.match(combinedOutput(result), /Frontend port .* is still occupied/)
assert.match(combinedOutput(result), /still held by a live listener/)
assert.equal(existsSync(fixture.stateFile), true, 'PID state was dropped for a localized listener')
assert.equal(foreign.listening, true, 'the localized listener was disturbed')
} finally {
await new Promise((resolve) => foreign.close(resolve))
await removeFixture(fixture)
}
},
)
}

// An unprivileged inventory can see that a socket is listening without being able to name its
// owner: `ss -p` omits `users:(...)` for another account's socket, and `lsof` cannot see it at
// all. An unattributable owner must never be read as "nothing is listening" - otherwise a
Expand Down
6 changes: 5 additions & 1 deletion scripts/dev-up.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,11 @@ function Get-PortListenerInventory {
foreach ($row in $rows) {
$fields = ($row -split '\s+') | Where-Object { $_ -ne "" }
if ($fields.Count -lt 5) { continue }
if ($fields[0] -ne "TCP" -or $fields[3] -ne "LISTENING") { continue }
# Windows localizes netstat's state token, so the state column cannot be
# matched. A listening socket is the only TCP row whose foreign address is
# the wildcard peer (0.0.0.0:0 or [::]:0); TIME_WAIT, CLOSE_WAIT and
# ESTABLISHED rows carry a real peer and are skipped, in every locale.
if ($fields[0] -ne "TCP" -or $fields[2] -notmatch '^(0\.0\.0\.0:0|\[::\]:0)$') { continue }
if ($fields[1] -notmatch ":(\d+)$" -or [int]$Matches[1] -ne $Port) { continue }
$listening = $true
$ownerPid = 0
Expand Down
41 changes: 33 additions & 8 deletions scripts/dev-up.sh
Original file line number Diff line number Diff line change
Expand Up @@ -375,21 +375,34 @@ DEFAULT_PORT_RELEASE_TIMEOUT_MS=30000
port_release_timeout_ms() {
local raw="${TASKDECK_DEV_UP_PORT_RELEASE_TIMEOUT_MS:-}"
if [[ -n "$raw" ]]; then
# Bounded to int32 so the deadline arithmetic matches the PowerShell launcher's [int] parse and
# cannot wrap on a 64-bit shell.
if [[ "$raw" =~ ^[0-9]{1,10}$ ]] && (( 10#$raw <= 2147483647 )); then
printf '%s\n' "$(( 10#$raw ))"
return 0
raw="${raw#"${raw%%[![:space:]]*}"}"
raw="${raw%"${raw##*[![:space:]]}"}"
if [[ -n "$raw" ]]; then
# Bounded to int32 so the deadline arithmetic cannot wrap on a 64-bit shell. Trim and
# normalize whitespace and signed zero to match the PowerShell launcher's parser edge cases.
if [[ "$raw" =~ ^-0+$ ]]; then
printf '0\n'
return 0
fi
if [[ "$raw" =~ ^\+?[0-9]{1,10}$ ]]; then
raw="${raw#+}"
if (( 10#$raw <= 2147483647 )); then
printf '%s\n' "$(( 10#$raw ))"
return 0
fi
fi
warn "Ignoring invalid TASKDECK_DEV_UP_PORT_RELEASE_TIMEOUT_MS '$raw'; using ${DEFAULT_PORT_RELEASE_TIMEOUT_MS} ms."
fi
warn "Ignoring invalid TASKDECK_DEV_UP_PORT_RELEASE_TIMEOUT_MS '$raw'; using ${DEFAULT_PORT_RELEASE_TIMEOUT_MS} ms."
fi
printf '%s\n' "$DEFAULT_PORT_RELEASE_TIMEOUT_MS"
}

now_ms() {
local raw
raw="$(date +%s%3N 2>/dev/null || true)"
if [[ "$raw" =~ ^[0-9]+$ ]]; then printf '%s\n' "$raw"; return 0; fi
# Some date implementations return bare seconds for %3N. Require an epoch-millisecond-sized
# value before trusting it, otherwise a 30-second deadline can become hours long.
if [[ "$raw" =~ ^[0-9]{12,}$ ]]; then printf '%s\n' "$raw"; return 0; fi
printf '%s000\n' "$(date +%s)"
}

Expand All @@ -408,7 +421,19 @@ port_listener_inventory() {
rows="$(ss -ltnH "sport = :$port" 2>/dev/null)" || return 2
elif command -v netstat >/dev/null 2>&1; then
all="$(netstat -an -p tcp 2>/dev/null)" || return 2
rows="$(printf '%s\n' "$all" | grep -E "[:.]${port}[[:space:]]" | grep -i 'LISTEN' || true)"
# The state token is localized and its spelling differs across netstat implementations, so it
# is not matched. A listening socket is the only row whose foreign address is the wildcard peer
# (0.0.0.0:0, [::]:0, *.*, 0.0.0.0:*, :::*); drained TIME_WAIT/CLOSE_WAIT rows carry a real peer.
rows="$(printf '%s\n' "$all" | awk -v port="$port" '
function ends_with_port(value) { return value ~ ("[:.]" port "$") }
function wild_peer(value) { return value ~ /^(0\.0\.0\.0|\[::\]|::|\*)[:.](0|\*)$/ }
{
protocol = tolower($1)
if (protocol !~ /^tcp[46]{0,2}$/) next
if (ends_with_port($2) && wild_peer($3)) { print; next }
if (ends_with_port($4) && wild_peer($5)) print
}
')"
else
return 2
fi
Expand Down
Loading