Skip to content

Commands disconnect SQL Server connections they do not own #10554

Description

@andreasjordan

Eight sites in public/ and private/ close a connection the caller owns. Invoke-DbaQuery is the one that started this and is described in full first; the complete inventory follows below, together with the places that already get it right.

The damage is invisible on a pooled connection - SMO transparently reopens it from the pool. It is real on anything session-scoped: -NonPooledConnection, a dedicated admin connection, temp tables, SET options, session context, sp_getapplock. It also makes the database-context leak in #10555 look unreproducible, because the accidental reconnect resets the current database.

Invoke-DbaQuery

When Invoke-DbaQuery decides it cannot reuse the passed-in SMO server object, it builds its own connection with NonPooledConnection = $true and closes it afterwards:

# if the given connection started out open, don't close it.
if ($connDbaInstanceParams.NonPooledConnection -and -not $startedWithAnOpenConnection) {
    $null = $server | Disconnect-DbaInstance -Verbose:$false
}

public/Invoke-DbaQuery.ps1:544-548

The assumption behind the comment does not hold: Connect-DbaInstance frequently hands back the same object that was passed in instead of a new connection, because it only copies the context when something actually differs (public/Connect-DbaInstance.ps1:699-745). In that case Invoke-DbaQuery disconnects the caller's connection.

Two ways into it, both verified on SQL Server 2022 with Connect-DbaInstance -NonPooledConnection:

  1. -Database naming the database the connection is already on. $startedWithAnOpenConnection compares ConnectionContext.DatabaseName (public/Invoke-DbaQuery.ps1:503), which is empty for a connection built by Connect-DbaInstance without -Database, while Connect-DbaInstance compares ConnectionContext.CurrentDatabase (public/Connect-DbaInstance.ps1:712), which is master. The two disagree, so nothing is copied and the caller's connection is closed.
  2. A -SqlCredential holding a Windows account. That sets ConnectAsUserName, which makes $startedWithAnOpenConnection false unconditionally (public/Invoke-DbaQuery.ps1:505). Then even Invoke-DbaQuery without -Database closes the caller's connection.

Steps to Reproduce

$server = Connect-DbaInstance -SqlInstance $instance -NonPooledConnection
$server.ConnectionContext.IsOpen                                  # True

$null = Invoke-DbaQuery -SqlInstance $server -Query "SELECT 1"
$server.ConnectionContext.IsOpen                                  # True

$null = Invoke-DbaQuery -SqlInstance $server -Database master -Query "SELECT 1"
$server.ConnectionContext.IsOpen                                  # False   <-- closed by dbatools
--- Windows auth (ConnectAsUserName []) ---
  IsOpen before                         : True
  IsOpen after Invoke-DbaQuery          : True
  IsOpen after -Database master         : False
--- SqlCredential with a Windows account (ConnectAsUserName [Admin@DOMAIN]) ---
  IsOpen before                         : True
  IsOpen after Invoke-DbaQuery          : False
  IsOpen after -Database master         : False

Session state is lost with it, because SMO transparently reopens the connection on next use:

$server = Connect-DbaInstance -SqlInstance $instance -NonPooledConnection
$null = $server.ConnectionContext.ExecuteNonQuery("CREATE TABLE #t (id int)")
$null = Invoke-DbaQuery -SqlInstance $server -Database master -Query "SELECT 1"
$server.ConnectionContext.ExecuteScalar("SELECT COUNT(*) FROM #t")
# Invalid object name '#t'.

The same defect elsewhere

Site Where the connection comes from Guard
Invoke-DbaQuery.ps1:547 Connect-DbaInstance -NonPooledConnection ownership inferred from parameters, see above
Get-DbaDbExtentDiff.ps1:191 Connect-DbaInstance -NonPooledConnection none
Install-DbaMaintenanceSolution.ps1:895 Connect-DbaInstance -NonPooledConnection none
Update-DbaMaintenanceSolution.ps1:185 Connect-DbaInstance -NonPooledConnection none
Invoke-DbaAdvancedRestore.ps1:601 and :609 Connect-DbaInstance (plain) none - and it disconnects after every backup file
Import-DbaBinaryFile.ps1:279 $tbl.Parent.Parent from a piped-in table none - the command never opens a connection at all
Add-DbaRegServerGroup.ps1:155 $reggroup.ParentServer none - never opened it either
Import-DbaSpConfigure.ps1:243-246 Connect-DbaInstance (plain) none

The three unguarded -NonPooledConnection sites carry the comment "Close non-pooled connection as this is not done automatically. If it is a reused Server SMO, connection will be opened again automatically on next request." - so the reopen was known; what the comment misses is that the session dies with it.

Invoke-DbaAdvancedRestore has the widest reach, because Restore-DbaDatabase connects once and passes that server object down. Note it connects with -Database master, so a caller object that is not on master gets copied first and is safe - it is the caller who is already on master whose connection gets closed.

Verified against SQL Server 2022 with Connect-DbaInstance -NonPooledConnection, using a temp table as the session marker and Get-DbaDatabase as a control:

Get-DbaDbExtentDiff                            session survived: False
Update-DbaMaintenanceSolution                  session survived: False
Restore-DbaDatabase (advanced restore)         session survived: False
Get-DbaDatabase (control, no disconnect)       session survived: True

private/functions/Invoke-DbaDbCorruption.ps1:159 and :168 disconnect unconditionally as well, but the function is private and effectively test-only.

Where this is already done right

These are the patterns to copy rather than invent something new:

  • $dacOpened - Export-DbaCredential, Export-DbaLinkedServer, Export-DbaInstance, Copy-DbaCredential, Copy-DbaLinkedServer, Copy-DbaDbMail, Invoke-DbaDbDecryptObject, Sync-DbaAvailabilityGroup, Start-DbaMigration. A caller-supplied dedicated admin connection is detected (Test-DacConnection / $dacConnected) and reused, and only a connection the command opened itself is closed.
  • $startedWithANonPooledConnection - Write-DbaDbTableData.ps1:227. The same command also restores the original database context at :807-810, which is prior art for Database-scoped SMO calls silently change the current database of the shared connection #10555.
  • $Newconnection - private/functions/Test-DbaRestoreVersion.ps1:88.

Connect-DbaInstance's own internal retries and private/scripts/updateTeppAsync.ps1 disconnect connections they genuinely own and need no change.

History

The disconnect was added for #6210 (Invoke-DbaQuery leaking connections it created), and the ConnectAsUserName condition comes from #7725 / #8491. Both are right in themselves - the gap is only that "did I create this connection?" is inferred from parameters instead of from what Connect-DbaInstance actually returned.

Suggested fix

Import-DbaBinaryFile and Add-DbaRegServerGroup never open a connection, so for those the disconnect can simply go away.

The remaining six need to know whether the connection is theirs, and the rule is the same everywhere: a command may only close what it opened itself. Connect-DbaInstance already knows this - it sets $isNewConnection internally - so the choice is between

  1. surfacing that, so any command can ask instead of infer, which also keeps the next command from getting it wrong, or
  2. a per-command flag, hand-rolled six more times in the shape of the existing $dacOpened / $startedWithANonPooledConnection, or
  3. an object identity check where the original input is still in scope, for example in Invoke-DbaQuery:
if ($connDbaInstanceParams.NonPooledConnection -and -not [object]::ReferenceEquals($server, $instance.InputObject)) {
    $null = $server | Disconnect-DbaInstance -Verbose:$false
}

Option 1 looks like the better deal given that eight sites got this wrong independently.

While in there: the DatabaseName vs CurrentDatabase mismatch between Invoke-DbaQuery and Connect-DbaInstance is worth aligning on its own - it also causes an unnecessary second connection for -Database master on a connection that is already on master.

Test

Per tests/CLAUDE.md, one regression test per fixed command: connect with -NonPooledConnection, create a temp table, run the command against that server object, then assert $server.ConnectionContext.IsOpen is still $true and the temp table is still there. A pooled connection cannot show this - it passes either way.


This text was created by Claude and reviewed by Andreas Jordan.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions