Skip to content

feat(workspace): guard statement execution - #920

Open
psteinroe wants to merge 8 commits into
refactor/code-actions-modulefrom
feat/guard-statement-execution
Open

psteinroe wants to merge 8 commits into
refactor/code-actions-modulefrom
feat/guard-statement-execution

Conversation

@psteinroe

@psteinroe psteinroe commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #990. (#907, #903 and #879 have merged since.)

Stacked on #907, which is stacked on #903 and #879. All three need to merge first.

Executing a statement from the editor had no guard rails:

select pg_sleep(600);                     -- ran for 10 minutes, nothing could stop it
select repeat('x', 20000000);             -- sent a 20 MB cell to the editor
select generate_series(1, 20000000);      -- Postgres produced all 20M rows to show 100
copy (select 1) to stdout;                -- "unexpected message: CopyOutResponse"
do $$ begin raise notice 'hello'; end $$; -- "Rows affected: 0", the notice was lost
create table t (id int);                  -- other files still reported `t` as unknown

#879 described truncation as "sqlx drains the rest of the result". What happened was that the pool's ping on release failed, the connection was closed, and Postgres kept running the query until it noticed the closed socket.

What changed

  • Timeout. A statement runs for at most db.statementTimeoutSecs seconds, 60 by default, before Postgres cancels it (57014, returned like any other error since fix(workspace): only connection failures put the database into backoff #903). The timeout is set per execution with set_config, not as a startup option, so it works through pgbouncer and Supavisor. 0 keeps the database's own statement_timeout.
  • Cancellation. $/cancelRequest for the workspace/executeCommand request cancels the statement in the database with pg_cancel_backend on the server's own pool. Before, the handler called the blocking workspace method directly, so the server could not handle any request until the statement finished. It now runs on a blocking thread. When tower-lsp drops the cancelled handler, a guard calls the new Workspace::cancel_statement_execution.
  • Size caps. Values are cut at 10,000 bytes, on a character boundary, and end with …. truncatedValues marks the result then. The rows that would push the values past 1 MB are dropped, and truncated marks the result.
  • Queries stop early. A select or values without into and without a data-modifying with runs as begin; declare ... no scroll cursor for <statement>; fetch 101 ...; commit, so Postgres stops after 101 rows. The position of an error still points into the statement. Any other statement runs to completion: the rows after the first 100 are read and dropped, and an insert ... returning of 1,000 rows inserts all 1,000. Closing the connection there would roll the statement back.
  • COPY. COPY ... FROM STDIN and COPY ... TO STDOUT are refused before they reach the database: "COPY ... TO STDOUT can't be executed from the editor. Use \copy in psql, or a file on the database server." COPY with a server file or a program still runs.
  • Notices. sqlx has no notice handler and only emits notices as tracing events (target sqlx::postgres::notice). The statement runs with a subscriber that collects these events and forwards every other event and span to the current subscriber. The result lists them as notices ("NOTICE: hello", "WARNING: careful"), and message ends with them, so showMessage shows them too.
  • Snapshot refresh. After a successful statement that may change the schema, i.e. anything but a query, insert, update, delete, merge or show, the connection's snapshot is dropped. The diagnostics refresh that the LSP runs after the command then loads the new schema.
  • While the statement runs, execute_statement no longer holds the read locks on the documents and settings, so editing a file or reloading the configuration doesn't wait for it.

docs/guides/configure_database.md describes all of this, and docs/schema.json and the TS bindings list the new setting.

Tests

Workspace tests, which run against the test database:

  • execute_statement_times_out: pg_sleep(10) with a 1 s timeout returns 57014.
  • execute_statement_can_be_cancelled: cancel_statement_execution stops a running pg_sleep(30), and the registry of running statements ends up empty.
  • execute_statement_stops_a_truncated_query_early: select generate_series(1, 50000000) and the next statement together take less than 3 s.
  • execute_statement_completes_a_truncated_command: a truncated insert ... returning inserts all its rows.
  • execute_statement_cuts_long_values, including a cut in the middle of an ä, and execute_statement_caps_the_result_size.
  • execute_statement_refuses_copy_with_the_client, execute_statement_returns_notices.
  • executed_ddl_refreshes_the_snapshot: after create table, another file loses its typecheck finding for the table.

LSP test cancel_request_cancels_the_executed_statement: sends $/cancelRequest while pg_sleep(30) runs. It checks that the response is RequestCancelled and that the query disappears from pg_stat_activity.

Risks and not covered

  • Wrapping a query in a cursor also changes what it does: rows after the 101st are never computed, so side effects of computing them (volatile functions, nextval) don't happen, the same as with FETCH_COUNT in psql.
  • Collecting notices replaces the default subscriber for the statement's future. Every other event and span is forwarded, but Span::current() inside it returns no span. Creating the subscriber rebuilds tracing's callsite interest cache once per execution.
  • A truncated non-query statement still makes Postgres send its whole result, which the server then reads. The statement timeout bounds that.
  • Cancellation needs the $/cancelRequest from the client. The VS Code extension doesn't send it yet.

Update:

  • Merged main through refactor(workspace): move the code actions into their own module #990, which moves the code actions into features/code_actions/. The execution code is now in features/code_actions/execute_statement/execution.rs (was workspace/server/statement_execution.rs), and notices.rs sits next to it. The new constants and result fields live in execute_statement.rs and are re-exported from features::code_actions as before. ExecuteStatementParams now carries the project_key from main, and the settings come from settings_for(project_key, path). SnapshotManager::clear_connection, which main dropped as unused, is back for the snapshot refresh after DDL. The default of allowStatementExecutions stays false as on main.
  • The registry of running statements is its own type, RunningStatements in execute_statement/running.rs, instead of a Mutex<Vec<RunningStatement>> on WorkspaceServer. start(&params) registers an execution and returns a guard and the cancel receiver. The guard removes the execution when it is dropped, so it is also removed if the execution panics, which the old retain after the call didn't cover. cancel(&params) cancels every execution of the statement. WorkspaceServer::execute_statement and cancel_statement_execution each call it in one line. Unit tests cover cancelling and the guard.
  • The COPY check moved from the server into execution::refusal, next to is_query and may_change_schema.
  • execute_statement_stops_a_truncated_query_early runs one statement before it starts the timer. Since the code actions run the linter, the first execution of a workspace also connects and loads the schema, which took longer than the 3 s the test allows on a loaded machine.

Verified with cargo clippy --workspace --all-targets -- -D warnings, cargo test -p pgls_workspace (206 passed), cargo test -p pgls_lsp, cargo test -p pgls_configuration, and just gen-lint (no changes).

Every sqlx error became a DatabaseConnectionError, and the connection
manager put every such error into the reconnect backoff, so an executed
statement that divides by zero disabled completions, hover and typecheck
for 5 to 60 seconds.

Only errors that mean the database could not be reached or refused the
connection start the backoff now. An error Postgres raises for an
executed statement is part of the result, with its SQLSTATE, detail,
hint and position in the document.
…uted statements

Executed statements ran on the pool that the language server uses for
its own queries, and sqlx returned the connection to the pool as the
statement left it. After an executed `begin`, later statements and the
server's own snapshot and typecheck queries ran inside that transaction,
and a `set search_path` applied to them as well.

Executed statements now run on a pool of their own. After each one, the
session is reset with `discard all`, after rolling back a transaction
the statement left open. The pool caches no prepared statements, which
`discard all` would drop.
Executed statements had no timeout and could not be cancelled, sent
values of any size to the editor, made Postgres produce the whole result
of a truncated query, failed on COPY with a protocol error, dropped the
notices, and left the schema snapshot stale after DDL.

- A statement runs for at most db.statementTimeoutSecs (60 by default).
- $/cancelRequest cancels it in the database. The language server runs
  it on a blocking thread, so it keeps handling requests meanwhile.
- A value is cut to 10,000 bytes and the result to 1 MB, both marked.
- A select or values runs in a cursor that fetches 101 rows. Other
  statements still run to completion.
- COPY FROM STDIN and TO STDOUT are refused with a clear message.
- Notices and warnings are returned and appended to the message.
- After a statement that may change the schema, the snapshot is
  reloaded.
/// with `…`, and the result is marked with `truncated_values`.
pub const EXECUTE_STATEMENT_MAX_VALUE_SIZE: usize = 10_000;

/// The most bytes of values that an executed statement returns. The rows that would exceed it are

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we should split code actions into their own module now that we have start expand and execute statement and maybe others


/// The statements that are being executed, to cancel them
#[cfg(feature = "db")]
running_statements: Mutex<Vec<RunningStatement>>,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should probably be encapsulated into its own structure and module

psteinroe added a commit that referenced this pull request Oct 26, 2026
Based on #879, which needs to merge first. It doesn't depend on #903, #907 or #920.

`allowStatementExecutionsAgainst` ignored the port. Patterns were matched against `host/database`, and the docs recommended `["localhost/*", "127.0.0.1/*"]`. A port forward, an SSH tunnel, `cloud-sql-proxy`, `fly proxy` or pgbouncer make a remote database reachable on `localhost` as well, on another port, so the recommended patterns also allowed executing statements against production:

```jsonc
"allowStatementExecutionsAgainst": ["localhost/*"]
// postgres://...@localhost:5432/postgres   allowed
// postgres://...@localhost:15432/postgres  allowed too, e.g. an SSH tunnel to production
```

There was also no way to name the port: `localhost:5432/*` never matched, because the target string had no port.

### What changed

- A pattern that names a port is matched against `host:port/database`: `127.0.0.1:54322/*` only allows port 54322, and `localhost:*/dev` allows `dev` on any port. A pattern names a port if its host part, the part before the first `/`, has exactly one `:`.
- A pattern without a port keeps matching `host/database` and allows every port, as before. The docs say so and explain why to name the port.
- An IPv6 address can't be combined with a port, since its colons are ambiguous. `::1/*` matches every port of `::1`, as before.
- The docs use port-specific patterns: `127.0.0.1:5432/*`, and `127.0.0.1:54322/*` for a local Supabase project. The config field now has a description, so `docs/schema.json`, the CLI reference and the TS bindings explain the matching.
- The disabled reason names the port and suggests a port-specific pattern: "Statement execution is disabled for `127.0.0.1:5432/postgres`. Add a matching pattern to `db.allowStatementExecutionsAgainst`, e.g. ["127.0.0.1:5432/*"]."
- `DatabaseSettings::default().allow_statement_executions` is `false`. It made no difference before, because the default also disables the connection, but a default should not allow executions.

### For the changelog

Behavior change: patterns in `db.allowStatementExecutionsAgainst` can name a port (`host:port/database`) and then only allow that port. Patterns without a port still allow every port. A pattern whose host part had exactly one `:`, which could never match before, now matches against `host:port/database`. Consider switching `localhost/*` to `localhost:5432/*`.

### Tests

- `patterns_with_a_port_only_match_that_port`, `patterns_without_a_port_match_every_port` (including `::1/*`).
- `connection_string_port_is_matched`: `localhost:54322/*` allows the connection string on port 54322 but not a tunnel on 15432.
- `disabled_reason_suggests_a_pattern_with_the_port`, for IPv6 too, and `statement_executions_are_disallowed_by_default`.
- The expected disabled reasons in the existing settings and workspace tests now include the port.

### Not covered

Existing configs with `localhost/*` keep allowing every local port. Making port-less patterns match only the default port would break configs without a warning, so this PR only changes the docs and the suggested pattern.
@psteinroe
psteinroe changed the base branch from fix/execute-statement-isolation to main October 26, 2026 13:04
…type

The registry of executed statements was a Mutex<Vec<RunningStatement>> on
the WorkspaceServer, and execute_statement and cancel_statement_execution
locked and searched it themselves.

RunningStatements in features/code_actions/execute_statement/running.rs
now owns it: start registers an execution and returns a guard and the
cancel receiver, and the guard removes the execution when dropped, also
if the execution panics. cancel cancels the executions of a statement.
The check for COPY with the client moves next to the other statement
checks in execution.rs.
…y test

Since the code actions run the linter, the first execution of a workspace
also connects and loads the schema, which can take more than the 3 s that
the test allows for the query.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant