From 7700441b46ebfab2c229bb7b45d7f8459fbf3cc9 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 4 Sep 2026 07:08:17 +0100 Subject: [PATCH 1/3] fix(dev-up): recognize localized netstat states --- scripts/ci/dev-up.test.mjs | 186 +++++++++++++++++++++++++++++++++++++ scripts/dev-up.ps1 | 4 +- scripts/dev-up.sh | 39 ++++++-- 3 files changed, 220 insertions(+), 9 deletions(-) diff --git a/scripts/ci/dev-up.test.mjs b/scripts/ci/dev-up.test.mjs index 9caa5d391..d33aa4707 100644 --- a/scripts/ci/dev-up.test.mjs +++ b/scripts/ci/dev-up.test.mjs @@ -659,6 +659,39 @@ 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', + 'exit /b 0', + '', + ].join('\r\n'), + ) + return netstatPath +} + async function installBashStubs(fakeBin) { const stubs = { node: String.raw`#!/usr/bin/env bash @@ -1183,6 +1216,125 @@ 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" +`, + ) + 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' ]] +`, + ) + 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', + }, + }) + 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-')) @@ -1864,6 +2016,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 diff --git a/scripts/dev-up.ps1 b/scripts/dev-up.ps1 index b0b5b2973..c69112b8d 100644 --- a/scripts/dev-up.ps1 +++ b/scripts/dev-up.ps1 @@ -413,7 +413,9 @@ 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; any non-empty value in the + # positional state column is conservative evidence of a matching TCP row. + if ($fields[0] -ne "TCP" -or [string]::IsNullOrWhiteSpace([string]$fields[3])) { continue } if ($fields[1] -notmatch ":(\d+)$" -or [int]$Matches[1] -ne $Port) { continue } $listening = $true $ownerPid = 0 diff --git a/scripts/dev-up.sh b/scripts/dev-up.sh index 39c908c37..f0deba907 100755 --- a/scripts/dev-up.sh +++ b/scripts/dev-up.sh @@ -375,13 +375,24 @@ 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" } @@ -389,7 +400,9 @@ 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)" } @@ -408,7 +421,17 @@ 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. Match + # only the local-address column and accept any non-empty state token as conservative evidence. + rows="$(printf '%s\n' "$all" | awk -v port="$port" ' + function ends_with_port(value) { return value ~ ("[:.]" port "$") } + { + protocol = tolower($1) + if (protocol !~ /^tcp[46]?$/) next + if (ends_with_port($2) && $4 != "") { print; next } + if (ends_with_port($4) && $6 != "") print + } + ')" else return 2 fi From 38710cbd8cc0e9fcda4ea4ba346b9d31cc672e89 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 6 Sep 2026 14:37:43 +0100 Subject: [PATCH 2/3] fix(dev-up): identify a listener by its wildcard peer, not by a localized state token (re-review HIGH-1); drained-connection negative fixtures in both twins --- scripts/ci/dev-up.test.mjs | 6 ++++++ scripts/dev-up.ps1 | 8 +++++--- scripts/dev-up.sh | 10 ++++++---- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/scripts/ci/dev-up.test.mjs b/scripts/ci/dev-up.test.mjs index 912a10151..b9e10b4b1 100644 --- a/scripts/ci/dev-up.test.mjs +++ b/scripts/ci/dev-up.test.mjs @@ -691,6 +691,7 @@ async function installPowerShellNetstatFallbackProbe(fixture) { [ '@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'), @@ -1290,6 +1291,8 @@ if (bash) { 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) @@ -1303,6 +1306,8 @@ 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)], { @@ -1313,6 +1318,7 @@ ${extractBashFunction(source, 'port_listener_inventory')} ...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) diff --git a/scripts/dev-up.ps1 b/scripts/dev-up.ps1 index c69112b8d..b13201623 100644 --- a/scripts/dev-up.ps1 +++ b/scripts/dev-up.ps1 @@ -413,9 +413,11 @@ function Get-PortListenerInventory { foreach ($row in $rows) { $fields = ($row -split '\s+') | Where-Object { $_ -ne "" } if ($fields.Count -lt 5) { continue } - # Windows localizes netstat's state token; any non-empty value in the - # positional state column is conservative evidence of a matching TCP row. - if ($fields[0] -ne "TCP" -or [string]::IsNullOrWhiteSpace([string]$fields[3])) { 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 diff --git a/scripts/dev-up.sh b/scripts/dev-up.sh index f0deba907..47ad1e993 100755 --- a/scripts/dev-up.sh +++ b/scripts/dev-up.sh @@ -421,15 +421,17 @@ 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 - # The state token is localized and its spelling differs across netstat implementations. Match - # only the local-address column and accept any non-empty state token as conservative evidence. + # 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]?$/) next - if (ends_with_port($2) && $4 != "") { print; next } - if (ends_with_port($4) && $6 != "") print + if (ends_with_port($2) && wild_peer($3)) { print; next } + if (ends_with_port($4) && wild_peer($5)) print } ')" else From baf753f907b0bd6f48c60a36a330fdc0cf751708 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 6 Sep 2026 14:42:19 +0100 Subject: [PATCH 3/3] fix(dev-up): accept BSD dual-stack tcp46 rows in the netstat fallback (verification MEDIUM-1) --- scripts/dev-up.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/dev-up.sh b/scripts/dev-up.sh index 47ad1e993..c09457223 100755 --- a/scripts/dev-up.sh +++ b/scripts/dev-up.sh @@ -429,7 +429,7 @@ port_listener_inventory() { function wild_peer(value) { return value ~ /^(0\.0\.0\.0|\[::\]|::|\*)[:.](0|\*)$/ } { protocol = tolower($1) - if (protocol !~ /^tcp[46]?$/) next + 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 }