fix(desktop): report the real POSIX drain refusal instead of a generic error
The managed-update termination probe (buildOwnedTerminationCommand) signals REFUSED, DARWIN_UNAVAILABLE, UNAVAILABLE, and TIMEOUT via a non-zero process exit code, but SshConnection.exec() throws on any non-zero remote exit before the caller can inspect stdout. terminateOwnedDashboardForUpdate's status classification (result === 'REFUSED' / 'DARWIN_UNAVAILABLE' / ...) therefore never ran for any of those outcomes; every one collapsed into the generic "Could not terminate the Desktop-owned remote serve for update." On a Darwin remote this fires on every managed SSH update, since the script unconditionally refuses to signal there. Align the probe with this file's own established convention (used by buildOwnedStaleTerminationCommand, pidIsOurDashboard, remoteProcessCreationTime): always exit 0 and let stdout carry the status.
This commit is contained in:
@@ -1414,6 +1414,54 @@ test('managed update drain never falls back to a bare kill when the identity-bou
|
||||
)
|
||||
})
|
||||
|
||||
test.skipIf(process.platform === 'win32')(
|
||||
'managed update drain reports the real POSIX refusal, not a generic transport error',
|
||||
async () => {
|
||||
// Regression for #124617: the termination probe prints REFUSED (and, on
|
||||
// Darwin, DARWIN_UNAVAILABLE) but used to exit non-zero for it. ssh.exec()
|
||||
// throws on any non-zero remote exit, so the specific message built below
|
||||
// from the probe's stdout was dead code in production — every refusal
|
||||
// collapsed into the generic "Could not terminate the Desktop-owned
|
||||
// remote serve for update." Run the real probe (not a canned string)
|
||||
// against a live-but-foreign local process so its actual exit code
|
||||
// decides the outcome, the same way the real SSH transport does.
|
||||
const dummy = spawn('sleep', ['5'], { stdio: 'ignore' })
|
||||
|
||||
try {
|
||||
const lock = ownedLock({ pid: dummy.pid })
|
||||
const rawLock = JSON.stringify(lock)
|
||||
|
||||
const ssh: Pick<SshConnection, 'exec'> = {
|
||||
async exec(cmd: string): Promise<string> {
|
||||
if (/cat .*lock\.json/.test(cmd)) {
|
||||
return rawLock
|
||||
}
|
||||
|
||||
if (/kill -0 /.test(cmd)) {
|
||||
return 'ALIVE'
|
||||
}
|
||||
|
||||
if (/fields\[19\]/.test(cmd)) {
|
||||
return lock.creationTime
|
||||
}
|
||||
|
||||
if (/print\("OWNED" if ok else "FOREIGN"\)/.test(cmd)) {
|
||||
return 'OWNED'
|
||||
}
|
||||
|
||||
// The real termination probe: execute it for real so its actual
|
||||
// exit code — not a canned string — is what drives the caller.
|
||||
return (await exec(cmd)).stdout
|
||||
}
|
||||
}
|
||||
|
||||
await assert.rejects(terminateOwnedDashboardForUpdate(ssh, lock), /identity changed at the signal boundary/)
|
||||
} finally {
|
||||
dummy.kill('SIGKILL')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
test('connect() respawns when the dashboard is wedged (alive pid, probe fails)', async () => {
|
||||
const reuseToken = 'stored'
|
||||
const lock = ownedLock({ tokenFingerprint: fingerprintToken(reuseToken) })
|
||||
|
||||
@@ -940,35 +940,35 @@ def owned(args):
|
||||
pidfd=None
|
||||
if sys.platform.startswith("linux"):
|
||||
if not hasattr(os,"pidfd_open") or not hasattr(signal,"pidfd_send_signal"):
|
||||
print("UNAVAILABLE");sys.exit(2)
|
||||
print("UNAVAILABLE");sys.exit(0)
|
||||
try:pidfd=os.pidfd_open(pid,0)
|
||||
except ProcessLookupError:print("ALREADY_STOPPED");sys.exit(0)
|
||||
except (OSError,PermissionError):print("UNAVAILABLE");sys.exit(2)
|
||||
except (OSError,PermissionError):print("UNAVAILABLE");sys.exit(0)
|
||||
|
||||
try:
|
||||
live_creation,live_args=identity_before_signal()
|
||||
if live_creation!=expected_creation or not owned(live_args):
|
||||
print("REFUSED");sys.exit(3)
|
||||
print("REFUSED");sys.exit(0)
|
||||
if (sys.platform=="darwin"):
|
||||
# Darwin has no pidfd-style signal binding. Refuse instead of accepting the
|
||||
# residual PID-reuse window between ps and os.kill; reconnect will surface
|
||||
# the still-running remote owner for an explicit retry.
|
||||
print("DARWIN_UNAVAILABLE");sys.exit(2)
|
||||
print("DARWIN_UNAVAILABLE");sys.exit(0)
|
||||
try:
|
||||
if pidfd is not None:signal.pidfd_send_signal(pidfd,signal.SIGTERM)
|
||||
else:os.kill(pid,signal.SIGTERM)
|
||||
except ProcessLookupError:print("ALREADY_STOPPED");sys.exit(0)
|
||||
if pidfd is not None:
|
||||
poller=select.poll();poller.register(pidfd,select.POLLIN)
|
||||
if not poller.poll(10000):print("TIMEOUT");sys.exit(4)
|
||||
if not poller.poll(10000):print("TIMEOUT");sys.exit(0)
|
||||
else:
|
||||
deadline=time.monotonic()+10
|
||||
while time.monotonic()<deadline:
|
||||
try:os.kill(pid,0)
|
||||
except ProcessLookupError:break
|
||||
except PermissionError:print("UNAVAILABLE");sys.exit(2)
|
||||
except PermissionError:print("UNAVAILABLE");sys.exit(0)
|
||||
time.sleep(.1)
|
||||
else:print("TIMEOUT");sys.exit(4)
|
||||
else:print("TIMEOUT");sys.exit(0)
|
||||
print("TERMINATED")
|
||||
finally:
|
||||
if pidfd is not None:os.close(pidfd)
|
||||
|
||||
Reference in New Issue
Block a user