Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

runtime: recover added in range-over-func loop body doesn't stop panic propagation / segfaults printing error #71675

Closed
newacorn opened this issue Feb 12, 2025 · 29 comments
Assignees
Labels
BugReport Issues describing a possible bug in the Go implementation. compiler/runtime Issues related to the Go compiler and/or runtime. Critical A critical problem that affects the availability or correctness of production systems built using Go NeedsFix The path to resolution is known, but the work has not been done.
Milestone

Comments

@newacorn
Copy link

Go version

go version go1.23.2 darwin/amd64

Output of go env in your module/workspace:

GO111MODULE='on'
GOARCH='amd64'
GOBIN=''
GOCACHE='/Users/acorn/Library/Caches/go-build'
GOENV='/Users/acorn/Library/Application Support/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFLAGS=''
GOHOSTARCH='amd64'
GOHOSTOS='darwin'
GOINSECURE=''
GOMODCACHE='/Users/acorn/go/pkg/mod'
GONOPROXY=''
GONOSUMDB=''
GOOS='darwin'
GOPATH='/Users/acorn/go'
GOPRIVATE=''
GOPROXY='direct'
GOROOT='/Users/acorn/go/pkg/mod/golang.org/[email protected]'
GOSUMDB='sum.golang.org'
GOTMPDIR=''
GOTOOLCHAIN='auto'
GOTOOLDIR='/Users/acorn/go/pkg/mod/golang.org/[email protected]/pkg/tool/darwin_amd64'
GOVCS=''
GOVERSION='go1.23.2'
GODEBUG=''
GOTELEMETRY='local'
GOTELEMETRYDIR='/Users/acorn/Library/Application Support/go/telemetry'
GCCGO='gccgo'
GOAMD64='v1'
AR='ar'
CC='clang'
CXX='clang++'
CGO_ENABLED='1'
GOMOD='/Users/acorn/go-demo/go.mod'
GOWORK=''
CGO_CFLAGS='-O2 -g'
CGO_CPPFLAGS=''
CGO_CXXFLAGS='-O2 -g'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='-O2 -g'
PKG_CONFIG='pkg-config'
GOGCCFLAGS='-fPIC -arch x86_64 -m64 -pthread -fno-caret-diagnostics -Qunused-arguments -fmessage-length=0 -ffile-prefix-map=/var/folders/6c/6skkl1zn2p9fcx2r70v0kyc00000gn/T/go-build362480746=/tmp/go-build -gno-record-gcc-switches -fno-common'

What did you do?

When traversing an iterative function, there are some peculiar behaviors that arise concerning the defer within the loop, the panic in the outer function surrounding the loop, and the panic within the iterative function itself.

Here is a simple example that can trigger a segmentation fault. Below, I provide several links containing reproductions of a few peculiar behaviors.

func main() {
	for range yieldInts {
		defer func() {
			log.Println("recover:called")
			recover()
		}()
	}
}

func yieldInts(yield func(int) bool) {
	if !yield(0) {
		return
	}
	log.Println("will panic")
	panic("stop")
}

output:

2025/02/12 14:34:15 will panic
2025/02/12 14:34:15 recover:called
fatal error: panic while printing panic value: type runtime.errorString
[signal SIGSEGV: segmentation violation code=0x1 addr=0x29 pc=0x9e0d613]
...

What did you see happen?

2025/02/12 14:34:15 will panic
2025/02/12 14:34:15 recover:called
fatal error: panic while printing panic value: type runtime.errorString
[signal SIGSEGV: segmentation violation code=0x1 addr=0x29 pc=0x9e0d613]
...

Bizarre Behavior One
Bizarre Behavior two
Bizarre Behavior three

This issue likely arises from the compiler's handling of the stack when transforming the for loop body into a range function.

What did you expect to see?

I hope to observe behavior that aligns exactly with what is described in this official documentation. It should not include those unpredictable and peculiar behaviors that it does not describe.

@gabyhelp gabyhelp added the BugReport Issues describing a possible bug in the Go implementation. label Feb 12, 2025
@zigo101
Copy link

zigo101 commented Feb 12, 2025

Callback behaves normally:

package main

import "log"

func main() {
	yieldInts(func(x int) bool {
		defer func() {
			log.Println("recover:called")
			recover()
		}()
		log.Println("x:", x)
		return true;
	})
}

func yieldInts(yield func(int) bool) {
	if !yield(0) {
		return
	}
	log.Println("will panic")
	panic("stop")
}

@zigo101
Copy link

zigo101 commented Feb 12, 2025

This means that the passing a function to a push iterator section in the containing blog article is not always right.

@newacorn
Copy link
Author

This means that the passing a function to a push iterator section in the containing blog article is not always right.

Segmentation faults are indeed a significant concern, especially since Golang is a type-safe language and my code does not involve any explicit pointer manipulations. If you are interested, you might want to take another look at the output of this code snippet, as well as a few PlayGround links that demonstrate the issue.

@zigo101
Copy link

zigo101 commented Feb 12, 2025

Using callback and using iterator are only equivalent when there are no defer statements in the callback and the loop body.

package main

import "fmt"

func Loop3(yield func() bool) {
		if (!yield()) {
			return
		}
		if (!yield()) {
			return
		}
		yield()
}

func main() {
	{
		defer fmt.Println()
		var n = 0
		for range Loop3 {
			defer fmt.Print(n)
			n++
		}
	}
	{
		var n = 0
		Loop3(func() bool {
			defer fmt.Print(n)
			n++
			return true
		})
		fmt.Println()
	}
}

@seankhliao seankhliao changed the title Range Over Function: Some unexpected bizarre behaviors and severe segmentation faults occur when certain conditions are met. runtime: recover added in range-over-func loop body doesn't stop panic propagation / segfaults printing error Feb 12, 2025
@gopherbot gopherbot added the compiler/runtime Issues related to the Go compiler and/or runtime. label Feb 12, 2025
@zigo101
Copy link

zigo101 commented Feb 12, 2025

The new title looks not right. The recover added in the range-over-func loop body should not catch panics.

OP's program should crash with an uncaught panic, instead of others.

(sorry I only noticed the example posted in the first comments, not the ones on go play)

@newacorn
Copy link
Author

The new title looks not right. The recover added in the range-over-func loop body should not catch panics.

OP's program should crash with an uncaught panic, instead of others.

If a range-over-func loop body defers a call, it runs when the outer function containing the loop returns, just as it would for any other kind of range loop. That is, the semantics of defer do not depend on what kind of value is being ranged over. It would be quite confusing if they did. That kind of dependence seems unworkable from a design perspective. Some people have proposed disallowing defer in a range-over-func loop body, but this would be a semantic change based on the kind of value being ranged over and seems similarly unworkable.
The loop body’s defer runs exactly when it looks like it would if you didn’t know anything special was happening in range-over-func.
cited here

If you modify the snippet I provided above to the version below, it will function correctly. However, this alters the semantics of the for loop, and as the official documentation states, traversing an iterator function should be semantically indistinguishable from traversing a slice. Indeed, there are other peculiar phenomena, as demonstrated by the playground links mentioned above.

package main

import "log"

func main() {
	defer func() {
		log.Println("recover:called")
		recover()
	}()
	yieldInts(func(x int) bool {

		log.Println("x:", x)
		return true
	})
}

func yieldInts(yield func(int) bool) {
	if !yield(0) {
		return
	}
	log.Println("will panic")
	panic("stop")
}

@zigo101
Copy link

zigo101 commented Feb 12, 2025

I found another bizarre behavior example: https://go.dev/play/p/gqqk-mrkqpB (changed a bit on Bizarre Behavior two).

package main

import "log"

func main() {
	for n := range yieldInts {
		defer func() {
			log.Println(n, "recover:called")
			log.Println(recover())
		}()
	}
	panic(3)
}

var i = 0

func yieldInts(yield func(int) bool) {
	for {
		if i == 2 {
			break
		}
		if !yield(i) {
			return
		}
		i++
	}
}

It looks the two deferred recover call both catch the panic, but don't remove the panic from the panic stack.

[edit] sorry, this should be the same as Bizarre Behavior two in principle.

@newacorn
Copy link
Author

I found another bizarre behavior example: https://go.dev/play/p/gqqk-mrkqpB (changed a bit on Bizarre Behavior two).

package main

import "log"

func main() {
for n := range yieldInts {
defer func() {
log.Println(n, "recover:called")
log.Println(recover())
}()
}
panic(3)
}

var i = 0

func yieldInts(yield func(int) bool) {
for {
if i == 2 {
break
}
if !yield(i) {
return
}
i++
}
}
It looks the two deferred recover call both catch the panic, but don't remove the panic from the panic stack.

[edit] sorry, this should be the same as Bizarre Behavior two in principle.

It is as you have described, but these issues necessitate the skills of more adept individuals to resolve, as they involve the complexities of compilation. Let us await their favorable news.

@zigo101
Copy link

zigo101 commented Feb 12, 2025

No need to wait. It is absolutely a bug. The behavior of Bizarre Behavior two the should be the same as

package main

import "log"

func main() {
	for range 1 {
		defer func() {
			log.Println("recover:called")
			log.Println(recover())
		}()
	}
	panic(3)
}

@newacorn
Copy link
Author

I found another bizarre behavior example: https://go.dev/play/p/gqqk-mrkqpB (changed a bit on Bizarre Behavior two).

package main

import "log"

func main() {
for n := range yieldInts {
defer func() {
log.Println(n, "recover:called")
log.Println(recover())
}()
}
panic(3)
}

var i = 0

func yieldInts(yield func(int) bool) {
for {
if i == 2 {
break
}
if !yield(i) {
return
}
i++
}
}
It looks the two deferred recover call both catch the panic, but don't remove the panic from the panic stack.

[edit] sorry, this should be the same as Bizarre Behavior two in principle.

Are you the author of the "Go101" series? It's truly a pleasure to meet you here. I've had the chance to read your work, and I must say, it's incredibly detailed and serves as an excellent reference book. It has been of great help to me, and for that, I am deeply grateful. Thank you very much.

@zigo101
Copy link

zigo101 commented Feb 12, 2025

Found a similar but different case: #71685

@dmitshur
Copy link
Contributor

CC @golang/runtime.

@dmitshur dmitshur added the NeedsInvestigation Someone must examine and confirm this is a valid issue and not a duplicate of an existing one. label Feb 12, 2025
@mknyszek mknyszek added this to the Backlog milestone Feb 12, 2025
@zigo101
Copy link

zigo101 commented Feb 13, 2025

The following code shows all kinds of weirdness:

package main

import "log"

func main() {
	defer func() {
		if v := recover(); v != nil {
			log.Printf("##, %T, %v\n", v, v)
		}
	}()
	for n := range yieldInts {
		log.Println("***", n)
		defer func() {
			if v := recover(); v != nil {
				log.Printf("@@, %T, %v\n", v, v)
			}
		}()
		log.Println("+++", n)
		panic(n)
	}
}

func yieldInts(yield func(int) bool) {
	log.Println("---")
	defer func() {
		if v := recover(); v != nil {
			log.Printf(">>, %T, %v\n", v, v)
			panic(v)
		}
	}()
	yield(0)
}

Outputs:

2025/02/13 21:18:54 ---
2025/02/13 21:18:54 *** 0
2025/02/13 21:18:54 +++ 0
2025/02/13 21:18:54 >>, int, 0
2025/02/13 21:18:54 @@, int, 0
2025/02/13 21:18:54 ---
2025/02/13 21:18:54 *** 0
2025/02/13 20:55:20 >>, *runtime.TypeAssertionError, %!s(PANIC=...)
2025/02/13 20:55:20 ##, *runtime.TypeAssertionError, %!s(PANIC=...)

The loop body is entered twice, which should be once only.
The second enter doesn't execute the loop body fully.

The iterator function is also called twice, but should be once only.

@zigo101
Copy link

zigo101 commented Feb 13, 2025

It looks a range function is required to resume panicking when it recovered a loop body panic.
I haven't found any official document mentions this.

@dr2chase
Copy link
Contributor

A range function is required to not disappear panics from the loop body, that is correct. We felt that it would be confusing for code in a function to have (for example) an obvious panic, that just vanished.

I'm still trying to figure out what's going here; it looks like maybe the recover is correctly processed up to the point where it tries to return from the recover, and then things look dubious. I think. But bad control flow across stack frames is going to cause all sorts of chaos.

@zigo101
Copy link

zigo101 commented Feb 14, 2025

A range function is required to not disappear panics from the loop body, that is correct. We felt that it would be confusing for code in a function to have (for example) an obvious panic, that just vanished.

Maybe, range functions should not be able to recover panics from loop bodies?

It is true that the yield function is called in the range function, but this is only actually true when the range function is called in the old callback way.

In the range-over-function way, it is weird to me that to think the code in loop body is called in range functions.

@gopherbot
Copy link
Contributor

Change https://go.dev/cl/649462 mentions this issue: cmd/compile: add recover-check following call to deferrangefunc

@zigo101
Copy link

zigo101 commented Feb 15, 2025

Before trying to fix the problem, there is a basic theory problem to be clarified: which of the behavior of the following two programs is correct? (They should behave the same but not now.)

package main

import "fmt"

func main() {
	defer foo()
}

func foo() {
	for range iter {}
	panic(123)
}

func iter(yield func(int) bool) {
	defer func() {
		fmt.Println(recover())
	}()
	yield(0)
}
package main

import "fmt"

func main() {
	defer foo()
}

func foo() {
	for range iter {
		panic(123)
	}
}

func iter(yield func(int) bool) {
	defer func() {
		fmt.Println(recover())
	}()
	yield(0)
}

@zigo101
Copy link

zigo101 commented Feb 15, 2025

Theory 1: The range function is divided into some small functions and the small functions are inlined in the containing function (foo here).

Theory 2: The range function is divided into some small functions and the small functions are not inlined in the containing function (foo here).

If theory 1 is adopted, then the recover call in range function should take effect. If theory 2 is adopted, then the recover call should not take effect.

Whatever, the rule that range functions are required to resume panicking is weird.

@dr2chase
Copy link
Contributor

dr2chase commented Feb 15, 2025

Why do you believe that they should behave the same, or that either behavior is incorrect?

Also code (in general) should ALWAYS behave the same whether inlining happens or not.

In both cases foo contains a panic and does not contain a defer (and hence, contains no recover that can apply to this panic) so foo should always panic. However, the iterator is buggy and recovers the panic of its yield function, which interferes with this obvious syntactic understanding of the code. To guard against buggy iterators, there is code inserted to detect when panics disappear, and re-panic with a diagnostic to blame the demonstrated-buggy iterator. When enough inlining happens, that checking code can often be optimized away.

The first yield function does not panic, so foo's panic(123) is seen.
The second yield function does panic, the iterator does recover() it, the checking code notes the missing panic, and issues its own diagnostic panic.

We felt that this was the best approach, balancing performance, explainability, testability, real-world utility, and the risk of tinkering with the panic code (the checking code can be expressed as an iterator combinator).

~/work/src/pr/a$ go run main.go
<nil>
panic: 123

goroutine 1 [running]:
main.foo()
	/Users/drchase/work/src/pr/a/main.go:11 +0x68
main.main()
	/Users/drchase/work/src/pr/a/main.go:7 +0x34
exit status 2
~/work/src/pr/a$ cd ../b
~/work/src/pr/b$ go run main.go
123
panic: runtime error: range function recovered a loop body panic and did not resume panicking

goroutine 1 [running]:
main.foo()
	/Users/drchase/work/src/pr/b/main.go:12 +0x60
main.main()
	/Users/drchase/work/src/pr/b/main.go:7 +0x34
exit status 2

This is the 1-variable checking combinator, which may help you understand what is going on:

func Check[U any](forall Seq[U]) Seq[U] {
	return func(body func(U) bool) {
		state := READY
		forall(func(u U) bool {
			tmp := state
			state = PANIC
			if tmp != READY {
				panic(fail[tmp])
			}
			ret := body(u)
			if ret {
				state = READY
			} else {
				state = DONE
			}
			return ret
		})
		if state == PANIC {
			panic(fail[MISSING_PANIC])
		}
		state = EXHAUSTED
	}
}

@zigo101
Copy link

zigo101 commented Feb 15, 2025

Panicking in the loop body and after the loop body should have not semantic difference. That is it. Otherwise, it is strange to users.

@newacorn
Copy link
Author

newacorn commented Feb 15, 2025

Panicking in the loop body and after the loop body should have not semantic difference. That is it. Otherwise, it is strange to users.

What happens if the iterator function recovers a panic in the loop body?Go Wiki: Rangefunc Experiment
The compiler and runtime will detect this case and trigger a [run-time panic]

Even if an exception is caught, a different exception is thrown again, which might be intended to align with the semantics of the call chain? However, logically, the iterator's recovery could entirely disregard the panic within the loop body. Reprocessing it in this way seems to hold little significance?

However, since only the function to which the loop body belongs and its parent functions have the opportunity to recover from a panic triggered by the loop body, this actually contradicts the semantics of the call chain. The official call chain is as follows: the parent function of the loop body -> the iterator function -> the function constructed by the loop body. As a result, the iterator function can only catch a panic but cannot recover it, which seems quite unusual. It appears that there has never been such a case before.

Indeed, in the official implementation, there are cases where a parent function cannot catch exceptions from its child functions, while a grandparent function can. However, those parent functions are compiler-generated, such as generating corresponding pointer-type methods for non-pointer type methods. In those scenarios, users cannot access the parent function, allowing for a seamless handling. This situation is different and requires more contemplation? But now that it has been released, it's too late. We'll just have to wait for the official implementation to conform to the current specification constraints.

@zigo101
Copy link

zigo101 commented Feb 15, 2025

Also code (in general) should ALWAYS behave the same whether inlining happens or not.

The "inline" in my descriptions are NOT the same as the general-speaking "inline". It is in logic and compiler optimization unrelated.

[edit]: sorry, missed a "not".

@zigo101
Copy link

zigo101 commented Feb 18, 2025

Maybe the best balancing tradeoff approach is to totally disable all recover calls in range functions. (a.k.a., adopt the Theory 2 mentioned above). This will avoid many confusions and easy the implementation.

If the Theory 1 is adopted, there will be still confusions in the following alike code.
The panic in the code will be catched (if Theory 1 is adiopted).
Some people might think this is weird.

package main

import "fmt"

func main() {
	defer foo()
	panic(123)
}

func foo() {
	for range iter {}
}

func iter(yield func(int) bool) {
	fmt.Println(recover())
	yield(0)
}

[edit] Sorry, it looks a new issue is needed to continue the discussion. It seems this is a different problem from both the current issue and #71685.

@gopherbot
Copy link
Contributor

Change https://go.dev/cl/650476 mentions this issue: cmd/compile, runtime: use deferreturn as target PC for recover from deferrangefunc

@github-project-automation github-project-automation bot moved this from Todo to Done in Go Compiler / Runtime Feb 19, 2025
@dmitshur dmitshur added NeedsFix The path to resolution is known, but the work has not been done. and removed NeedsInvestigation Someone must examine and confirm this is a valid issue and not a duplicate of an existing one. labels Feb 19, 2025
@dmitshur dmitshur modified the milestones: Backlog, Go1.25 Feb 19, 2025
@dr2chase dr2chase assigned dr2chase and unassigned dr2chase Feb 19, 2025
@dr2chase
Copy link
Contributor

@gopherbot, this needs a backport to 1.24 and 1.23. The fix is simple, and this was supposed to work.

@dr2chase
Copy link
Contributor

@gopherbot, please backport this.

@gopherbot
Copy link
Contributor

Backport issue(s) opened: #71839 (for 1.23), #71840 (for 1.24).

Remember to create the cherry-pick CL(s) as soon as the patch is submitted to master, according to https://go.dev/wiki/MinorReleases.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
BugReport Issues describing a possible bug in the Go implementation. compiler/runtime Issues related to the Go compiler and/or runtime. Critical A critical problem that affects the availability or correctness of production systems built using Go NeedsFix The path to resolution is known, but the work has not been done.
Projects
Development

No branches or pull requests

7 participants