From b2d4b7746d12ff508b6669dec4982c9be5774a36 Mon Sep 17 00:00:00 2001 From: Rui Zhao Date: Sat, 17 Oct 2026 12:15:47 +0800 Subject: [PATCH 1/3] Use separate sigjmp_buf variables in nested psql function calls Ctrl-C at an included script's \prompt can make an interactive psql session exit unexpectedly after returning from a nested MainLoop call. For example, with inner.sql containing SELECT 111;, this line in an outer script triggers the problem: \i inner.sql \prompt 'OUTER: ' answer Run the outer script with \i from an interactive psql session. Both MainLoop calls use sigsetjmp(sigint_interrupt_jmp, 1), with the same global variable. The inner call overwrites the outer call's saved context and returns without restoring it. The outer call is still processing the same line, so it has not reached the next iteration's sigsetjmp. The following \prompt sets sigint_interrupt_enabled to true. On Ctrl-C, psql_cancel_callback calls siglongjmp using the inner MainLoop's context after that call has returned. Replace the global sigjmp_buf with local sigjmp_buf variables and a global PG_exception_stack pointer, as the backend does for nested PG_TRY blocks. Each function saves the previous pointer, sets it to its local variable, and restores it before returning. The SIGINT callback uses siglongjmp(*PG_exception_stack, 1). Convert handleCopyIn and the callers of simple_prompt_extended too, so their SIGINT cleanup still runs without overwriting the calling function's saved context. Add a TAP test for the nested-script case and checks for cancellation and retry of COPY, \prompt, \password, and \connect with password authentication. --- src/bin/psql/command.c | 18 +++- src/bin/psql/common.c | 14 ++- src/bin/psql/common.h | 2 +- src/bin/psql/copy.c | 7 +- src/bin/psql/input.c | 8 +- src/bin/psql/mainloop.c | 19 ++-- src/bin/psql/meson.build | 1 + src/bin/psql/t/040_interrupt.pl | 183 ++++++++++++++++++++++++++++++++ 8 files changed, 229 insertions(+), 23 deletions(-) create mode 100644 src/bin/psql/t/040_interrupt.pl diff --git a/src/bin/psql/command.c b/src/bin/psql/command.c index 5737f62d2da..3c5b76a0cb6 100644 --- a/src/bin/psql/command.c +++ b/src/bin/psql/command.c @@ -2554,6 +2554,8 @@ exec_command_password(PsqlScanState scan_state, bool active_branch) char *pw2 = NULL; PQExpBufferData buf; PromptInterruptContext prompt_ctx; + sigjmp_buf local_sigjmp_buf; + sigjmp_buf *save_exception_stack = PG_exception_stack; if (user == NULL) { @@ -2569,16 +2571,18 @@ exec_command_password(PsqlScanState scan_state, bool active_branch) } /* Set up to let SIGINT cancel simple_prompt_extended() */ - prompt_ctx.jmpbuf = sigint_interrupt_jmp; + prompt_ctx.jmpbuf = &local_sigjmp_buf; prompt_ctx.enabled = &sigint_interrupt_enabled; prompt_ctx.canceled = false; initPQExpBuffer(&buf); printfPQExpBuffer(&buf, _("Enter new password for user \"%s\": "), user); + PG_exception_stack = &local_sigjmp_buf; pw1 = simple_prompt_extended(buf.data, false, &prompt_ctx); if (!prompt_ctx.canceled) pw2 = simple_prompt_extended("Enter it again: ", false, &prompt_ctx); + PG_exception_stack = save_exception_stack; if (prompt_ctx.canceled) { @@ -2642,9 +2646,11 @@ exec_command_prompt(PsqlScanState scan_state, bool active_branch, { char *result; PromptInterruptContext prompt_ctx; + sigjmp_buf local_sigjmp_buf; + sigjmp_buf *save_exception_stack = PG_exception_stack; /* Set up to let SIGINT cancel simple_prompt_extended() */ - prompt_ctx.jmpbuf = sigint_interrupt_jmp; + prompt_ctx.jmpbuf = &local_sigjmp_buf; prompt_ctx.enabled = &sigint_interrupt_enabled; prompt_ctx.canceled = false; @@ -2658,7 +2664,9 @@ exec_command_prompt(PsqlScanState scan_state, bool active_branch, if (!pset.inputfile) { + PG_exception_stack = &local_sigjmp_buf; result = simple_prompt_extended(prompt_text, true, &prompt_ctx); + PG_exception_stack = save_exception_stack; } else { @@ -3879,12 +3887,15 @@ prompt_for_password(const char *username, bool *canceled) { char *result; PromptInterruptContext prompt_ctx; + sigjmp_buf local_sigjmp_buf; + sigjmp_buf *save_exception_stack = PG_exception_stack; /* Set up to let SIGINT cancel simple_prompt_extended() */ - prompt_ctx.jmpbuf = sigint_interrupt_jmp; + prompt_ctx.jmpbuf = &local_sigjmp_buf; prompt_ctx.enabled = &sigint_interrupt_enabled; prompt_ctx.canceled = false; + PG_exception_stack = &local_sigjmp_buf; if (username == NULL || username[0] == '\0') result = simple_prompt_extended("Password: ", false, &prompt_ctx); else @@ -3895,6 +3906,7 @@ prompt_for_password(const char *username, bool *canceled) result = simple_prompt_extended(prompt_text, false, &prompt_ctx); pfree(prompt_text); } + PG_exception_stack = save_exception_stack; if (canceled) *canceled = prompt_ctx.canceled; diff --git a/src/bin/psql/common.c b/src/bin/psql/common.c index 788555b9782..7b5bff8141d 100644 --- a/src/bin/psql/common.c +++ b/src/bin/psql/common.c @@ -297,7 +297,7 @@ NoticeProcessor(void *arg, const char *message) * cancel_pressed during long-running loops. However, that won't work when * blocked on user input (in readline() or fgets()). In those places, we * set sigint_interrupt_enabled true while blocked, instructing the signal - * catcher to longjmp through sigint_interrupt_jmp. We assume readline and + * catcher to longjmp through PG_exception_stack. We assume readline and * fgets are coded to handle possible interruption. * * On Windows, currently this does not work, so control-C is less useful @@ -305,7 +305,14 @@ NoticeProcessor(void *arg, const char *message) */ volatile sig_atomic_t sigint_interrupt_enabled = false; -sigjmp_buf sigint_interrupt_jmp; +/* + * The SIGINT callback passes *PG_exception_stack to siglongjmp(). Functions + * that use sigsetjmp() must use a local sigjmp_buf, save the previous pointer, + * and restore it before returning. Otherwise, siglongjmp() could use the + * context of a function that has already returned. Call sigsetjmp() before + * setting sigint_interrupt_enabled to true. + */ +sigjmp_buf *PG_exception_stack = NULL; static void psql_cancel_callback(void) @@ -315,7 +322,8 @@ psql_cancel_callback(void) if (sigint_interrupt_enabled) { sigint_interrupt_enabled = false; - siglongjmp(sigint_interrupt_jmp, 1); + Assert(PG_exception_stack != NULL); + siglongjmp(*PG_exception_stack, 1); } #endif diff --git a/src/bin/psql/common.h b/src/bin/psql/common.h index 58b784cc058..3ff76335cb6 100644 --- a/src/bin/psql/common.h +++ b/src/bin/psql/common.h @@ -25,7 +25,7 @@ extern void NoticeProcessor(void *arg, const char *message); extern volatile sig_atomic_t sigint_interrupt_enabled; -extern sigjmp_buf sigint_interrupt_jmp; +extern sigjmp_buf *PG_exception_stack; extern void psql_setup_cancel_handler(void); diff --git a/src/bin/psql/copy.c b/src/bin/psql/copy.c index 1b8d6c7687f..e4a2090ce11 100644 --- a/src/bin/psql/copy.c +++ b/src/bin/psql/copy.c @@ -512,6 +512,8 @@ handleCopyOut(PGconn *conn, FILE *copystream, PGresult **res) bool handleCopyIn(PGconn *conn, FILE *copystream, bool isbinary, PGresult **res) { + sigjmp_buf local_sigjmp_buf; + sigjmp_buf *save_exception_stack = PG_exception_stack; bool OK; char buf[COPYBUFSIZ]; bool showprompt; @@ -526,7 +528,7 @@ handleCopyIn(PGconn *conn, FILE *copystream, bool isbinary, PGresult **res) * Establish longjmp destination for exiting from wait-for-input. (This is * only effective while sigint_interrupt_enabled is TRUE.) */ - if (sigsetjmp(sigint_interrupt_jmp, 1) != 0) + if (sigsetjmp(local_sigjmp_buf, 1) != 0) { /* got here with longjmp */ @@ -540,6 +542,8 @@ handleCopyIn(PGconn *conn, FILE *copystream, bool isbinary, PGresult **res) goto copyin_cleanup; } + PG_exception_stack = &local_sigjmp_buf; + /* Issue initial prompt if interactive input */ if (showprompt && !pset.quiet) puts(_("Enter data to be copied followed by a newline.\n" @@ -698,6 +702,7 @@ handleCopyIn(PGconn *conn, FILE *copystream, bool isbinary, PGresult **res) OK = false; copyin_cleanup: + PG_exception_stack = save_exception_stack; /* * Clear the EOF flag on the stream, in case copying ended due to an EOF diff --git a/src/bin/psql/input.c b/src/bin/psql/input.c index 6d37359fc96..2df1133b101 100644 --- a/src/bin/psql/input.c +++ b/src/bin/psql/input.c @@ -61,7 +61,7 @@ static void finishInput(void); * * The result is a malloc'd string. * - * Caller *must* have set up sigint_interrupt_jmp before calling. + * Caller *must* have set up PG_exception_stack before calling. */ char * gets_interactive(const char *prompt, PQExpBuffer query_buf) @@ -85,7 +85,7 @@ gets_interactive(const char *prompt, PQExpBuffer query_buf) /* Make current query_buf available to tab completion callback */ tab_completion_query_buf = query_buf; - /* Enable SIGINT to longjmp to sigint_interrupt_jmp */ + /* Enable SIGINT to longjmp through PG_exception_stack */ sigint_interrupt_enabled = true; result = readline(prompt); @@ -177,7 +177,7 @@ pg_send_history(PQExpBuffer history_buf) * Gets a line of noninteractive input from a file (which could be stdin). * The result is a malloc'd string, or NULL on EOF or input error. * - * Caller *must* have set up sigint_interrupt_jmp before calling. + * Caller *must* have set up PG_exception_stack before calling. * * Note: we re-use a static PQExpBuffer for each call. This is to avoid * leaking memory if interrupted by SIGINT. @@ -198,7 +198,7 @@ gets_fromFile(FILE *source) { char *result; - /* Enable SIGINT to longjmp to sigint_interrupt_jmp */ + /* Enable SIGINT to longjmp through PG_exception_stack */ sigint_interrupt_enabled = true; /* Get some data */ diff --git a/src/bin/psql/mainloop.c b/src/bin/psql/mainloop.c index c3f85a609bc..cf1a967f56d 100644 --- a/src/bin/psql/mainloop.c +++ b/src/bin/psql/mainloop.c @@ -32,6 +32,8 @@ const PsqlScanCallbacks psqlscan_callbacks = { int MainLoop(FILE *source) { + sigjmp_buf local_sigjmp_buf; + sigjmp_buf *save_exception_stack = PG_exception_stack; PsqlScanState scan_state; /* lexer working state */ ConditionalStack cond_stack; /* \if status stack */ volatile PQExpBuffer query_buf; /* buffer for query being accumulated */ @@ -100,11 +102,9 @@ MainLoop(FILE *source) } /* - * Establish longjmp destination for exiting from wait-for-input. We - * must re-do this each time through the loop for safety, since the - * jmpbuf might get changed during command execution. + * Establish longjmp destination for exiting from wait-for-input. */ - if (sigsetjmp(sigint_interrupt_jmp, 1) != 0) + if (sigsetjmp(local_sigjmp_buf, 1) != 0) { /* got here with longjmp */ @@ -141,6 +141,8 @@ MainLoop(FILE *source) } } + PG_exception_stack = &local_sigjmp_buf; + fflush(stdout); /* @@ -641,14 +643,9 @@ MainLoop(FILE *source) successResult = EXIT_USER; } - /* - * Let's just make real sure the SIGINT handler won't try to use - * sigint_interrupt_jmp after we exit this routine. If there is an outer - * MainLoop instance, it will reset sigint_interrupt_jmp to point to - * itself at the top of its loop, before any further interactive input - * happens. - */ + /* Restore the caller's PG_exception_stack before returning. */ sigint_interrupt_enabled = false; + PG_exception_stack = save_exception_stack; destroyPQExpBuffer(query_buf); destroyPQExpBuffer(previous_buf); diff --git a/src/bin/psql/meson.build b/src/bin/psql/meson.build index 922b2845267..debdaf8294c 100644 --- a/src/bin/psql/meson.build +++ b/src/bin/psql/meson.build @@ -78,6 +78,7 @@ tests += { 't/010_tab_completion.pl', 't/020_cancel.pl', 't/030_pager.pl', + 't/040_interrupt.pl', ], }, } diff --git a/src/bin/psql/t/040_interrupt.pl b/src/bin/psql/t/040_interrupt.pl new file mode 100644 index 00000000000..f1f5a9e9786 --- /dev/null +++ b/src/bin/psql/t/040_interrupt.pl @@ -0,0 +1,183 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +plan skip_all => 'sending SIGINT on Windows terminates the test itself' + if $windows_os; +plan skip_all => 'IO::Pty is needed to run this test' + unless eval { require IO::Pty; 1 }; + +my $node = PostgreSQL::Test::Cluster->new('main'); +$node->init; + +my $hba = $node->data_dir . '/pg_hba.conf'; +my $old_hba = slurp_file($hba); +open(my $hba_fh, '>', $hba) or die "open $hba: $!"; +print $hba_fh "local all interrupt_user scram-sha-256\n"; +print $hba_fh $old_hba; +close($hba_fh) or die "close $hba: $!"; + +$node->start; +$node->safe_psql('postgres', + 'CREATE TABLE copy_log (id int); CREATE ROLE interrupt_user LOGIN'); + +my $inner = $node->basedir . '/inner.sql'; +my $outer = $node->basedir . '/outer.sql'; +append_to_file($inner, "SELECT 111;\n"); +append_to_file($outer, "\\i $inner \\prompt 'OUTER: ' answer\n"); +my $password_script = $node->basedir . '/password_then_prompt.sql'; +append_to_file($password_script, + "\\password interrupt_user \\prompt 'AFTER: ' answer\n"); + +# simple_prompt_extended() can write to /dev/tty or stderr. Accept either +# stream, and discard the matched output before issuing the next command. +sub query_until +{ + my ($h, $until, $query) = @_; + $h->{timeout}->start(); + $h->{stdin} .= $query; + $h->{run}->pump() until ($h->{stdout} . $h->{stderr}) =~ $until; + my $output = $h->{stdout} . $h->{stderr}; + $h->{stdout} = ''; + $h->{stderr} = ''; + return $output; +} + +my $ready = qr/ready> /; + +for my $no_readline (1, 0) +{ + next + if !$no_readline + && ((!defined($ENV{with_readline}) || $ENV{with_readline} ne 'yes') + || defined($ENV{SKIP_READLINE_TESTS})); + + my $mode = $no_readline ? 'without readline' : 'with readline'; + my $h = $node->interactive_psql('postgres', + extra_params => $no_readline ? ['-n'] : []); + query_until( + $h, + qr/interrupt-ready\r?\nready> /, + "\\set PROMPT1 'ready> '\n\\echo interrupt-ready\n"); + + # After the inner MainLoop returns, Ctrl-C during \prompt on the same + # line must use the context saved by sigsetjmp in the outer MainLoop. + query_until($h, qr/OUTER: /, "\\i $outer\n"); + $h->{run}->signal('INT'); + query_until($h, $ready, ''); + like(query_until($h, $ready, "SELECT 222;\n"), + qr/\r?\n222\r?\n/, + "canceling a prompt after nested \\i preserves the session, $mode"); + + # Password input must not overwrite MainLoop's sigjmp_buf either. A + # later \prompt on the same line can set sigint_interrupt_enabled to true + # before MainLoop calls sigsetjmp again. + query_until( + $h, + qr/Enter new password for user "interrupt_user": /, + "\\i $password_script\n"); + query_until($h, qr/Enter it again: /, "test-password\n"); + query_until($h, qr/AFTER: /, "test-password\n"); + $h->{run}->signal('INT'); + query_until($h, $ready, ''); + like( + query_until($h, $ready, "SELECT 223;\n"), + qr/\r?\n223\r?\n/, + "canceling a prompt after password input preserves the session, $mode" + ); + + # COPY must handle the interrupt itself and finish the COPY protocol, + # rather than jumping directly to MainLoop. + $node->safe_psql('postgres', 'TRUNCATE copy_log'); + query_until($h, qr/>> /, "COPY copy_log FROM STDIN;\n"); + query_until($h, qr/>> /, "1\n"); + $h->{run}->signal('INT'); + query_until($h, $ready, ''); + is($node->safe_psql('postgres', 'SELECT count(*) FROM copy_log'), + '0', "canceling COPY rolls back its rows, $mode"); + like(query_until($h, $ready, "SELECT 333;\n"), + qr/\r?\n333\r?\n/, + "connection remains usable after canceling COPY, $mode"); + + query_until($h, qr/>> /, "COPY copy_log FROM STDIN (FORMAT binary);\n"); + $h->{run}->signal('INT'); + query_until($h, $ready, ''); + like(query_until($h, $ready, "SELECT 444;\n"), + qr/\r?\n444\r?\n/, + "connection remains usable after canceling binary COPY, $mode"); + + query_until($h, qr/>> /, "COPY copy_log FROM STDIN;\n"); + query_until($h, $ready, "2\n\\.\n"); + is($node->safe_psql('postgres', 'SELECT id FROM copy_log'), + '2', "COPY can subsequently complete, $mode"); + + query_until($h, qr/VALUE: /, "\\prompt 'VALUE: ' answer\n"); + $h->{run}->signal('INT'); + query_until($h, $ready, ''); + query_until($h, qr/VALUE: /, "\\prompt 'VALUE: ' answer\n"); + query_until($h, $ready, "prompt-ok\n"); + like(query_until($h, $ready, "\\echo :answer\n"), + qr/prompt-ok/, + "interactive prompt can be retried after cancellation, $mode"); + + $node->safe_psql('postgres', 'ALTER ROLE interrupt_user PASSWORD NULL'); + for my $second_prompt (0, 1) + { + query_until( + $h, + qr/Enter new password for user "interrupt_user": /, + "\\password interrupt_user\n"); + query_until($h, qr/Enter it again: /, "test-password\n") + if $second_prompt; + $h->{run}->signal('INT'); + query_until($h, $ready, ''); + is( $node->safe_psql( + 'postgres', + q{SELECT rolpassword IS NULL FROM pg_authid WHERE rolname = 'interrupt_user'} + ), + 't', + "canceling password prompt $second_prompt leaves password unchanged, $mode" + ); + } + query_until( + $h, + qr/Enter new password for user "interrupt_user": /, + "\\password interrupt_user\n"); + query_until($h, qr/Enter it again: /, "test-password\n"); + query_until($h, $ready, "test-password\n"); + is( $node->safe_psql( + 'postgres', + q{SELECT rolpassword IS NOT NULL FROM pg_authid WHERE rolname = 'interrupt_user'} + ), + 't', + "password change succeeds after cancellation, $mode"); + + query_until( + $h, + qr/Password for user interrupt_user: /, + "\\connect postgres interrupt_user\n"); + $h->{run}->signal('INT'); + query_until($h, $ready, ''); + like(query_until($h, $ready, "SELECT 555;\n"), + qr/\r?\n555\r?\n/, + "canceling connection password preserves the old connection, $mode"); + query_until( + $h, + qr/Password for user interrupt_user: /, + "\\connect postgres interrupt_user\n"); + query_until($h, $ready, "test-password\n"); + like( + query_until($h, $ready, "SELECT current_user;\n"), + qr/\r?\ninterrupt_user\r?\n/, + "password connection succeeds after cancellation, $mode"); + + ok($h->quit, "psql exits normally, $mode"); +} + +$node->stop; +done_testing(); -- 2.43.7