diff --git a/src/internal/synctest/synctest_test.go b/src/internal/synctest/synctest_test.go
index 010679b070f87a3167df8d6e0d689cc89d926213..c0e126e3fcd77be7d2923a9cb9b885c9ea130ce8 100644
--- a/src/internal/synctest/synctest_test.go
+++ b/src/internal/synctest/synctest_test.go
@@ -191,6 +191,18 @@ func TestTimeAfter(t *testing.T) {
 	})
 }
 
+func TestTimerAfterBubbleExit(t *testing.T) {
+	run := false
+	synctest.Run(func() {
+		time.AfterFunc(1*time.Second, func() {
+			run = true
+		})
+	})
+	if run {
+		t.Errorf("timer ran before bubble exit")
+	}
+}
+
 func TestTimerFromOutsideBubble(t *testing.T) {
 	tm := time.NewTimer(10 * time.Millisecond)
 	synctest.Run(func() {
@@ -308,6 +320,18 @@ func TestDeadlockChild(t *testing.T) {
 	})
 }
 
+func TestDeadlockTicker(t *testing.T) {
+	defer wantPanic(t, "deadlock: all goroutines in bubble are blocked")
+	synctest.Run(func() {
+		go func() {
+			for range time.Tick(1 * time.Second) {
+				t.Errorf("ticker unexpectedly ran")
+				return
+			}
+		}()
+	})
+}
+
 func TestCond(t *testing.T) {
 	synctest.Run(func() {
 		var mu sync.Mutex
diff --git a/src/runtime/synctest.go b/src/runtime/synctest.go
index b197758ad9348274d0ef389d627bcf1e37fcfdaf..36d6fa67c7158656516e8ee1794d3147f11796de 100644
--- a/src/runtime/synctest.go
+++ b/src/runtime/synctest.go
@@ -5,6 +5,7 @@
 package runtime
 
 import (
+	"internal/runtime/sys"
 	"unsafe"
 )
 
@@ -15,7 +16,9 @@ type synctestGroup struct {
 	now     int64 // current fake time
 	root    *g    // caller of synctest.Run
 	waiter  *g    // caller of synctest.Wait
+	main    *g    // goroutine started by synctest.Run
 	waiting bool  // true if a goroutine is calling synctest.Wait
+	done    bool  // true if main has exited
 
 	// The group is active (not blocked) so long as running > 0 || active > 0.
 	//
@@ -60,6 +63,9 @@ func (sg *synctestGroup) changegstatus(gp *g, oldval, newval uint32) {
 	case _Gdead:
 		isRunning = false
 		totalDelta--
+		if gp == sg.main {
+			sg.done = true
+		}
 	case _Gwaiting:
 		if gp.waitreason.isIdleInSynctest() {
 			isRunning = false
@@ -167,24 +173,32 @@ func synctestRun(f func()) {
 	if gp.syncGroup != nil {
 		panic("synctest.Run called from within a synctest bubble")
 	}
-	gp.syncGroup = &synctestGroup{
+	sg := &synctestGroup{
 		total:   1,
 		running: 1,
 		root:    gp,
 	}
 	const synctestBaseTime = 946684800000000000 // midnight UTC 2000-01-01
-	gp.syncGroup.now = synctestBaseTime
-	gp.syncGroup.timers.syncGroup = gp.syncGroup
-	lockInit(&gp.syncGroup.mu, lockRankSynctest)
-	lockInit(&gp.syncGroup.timers.mu, lockRankTimers)
+	sg.now = synctestBaseTime
+	sg.timers.syncGroup = sg
+	lockInit(&sg.mu, lockRankSynctest)
+	lockInit(&sg.timers.mu, lockRankTimers)
+
+	gp.syncGroup = sg
 	defer func() {
 		gp.syncGroup = nil
 	}()
 
-	fv := *(**funcval)(unsafe.Pointer(&f))
-	newproc(fv)
+	// This is newproc, but also records the new g in sg.main.
+	pc := sys.GetCallerPC()
+	systemstack(func() {
+		fv := *(**funcval)(unsafe.Pointer(&f))
+		sg.main = newproc1(fv, gp, pc, false, waitReasonZero)
+		pp := getg().m.p.ptr()
+		runqput(pp, sg.main, true)
+		wakep()
+	})
 
-	sg := gp.syncGroup
 	lock(&sg.mu)
 	sg.active++
 	for {
@@ -209,6 +223,10 @@ func synctestRun(f func()) {
 		if next < sg.now {
 			throw("time went backwards")
 		}
+		if sg.done {
+			// Time stops once the bubble's main goroutine has exited.
+			break
+		}
 		sg.now = next
 	}
 
diff --git a/src/testing/synctest/synctest.go b/src/testing/synctest/synctest.go
index 90efc789de972ea50c831f4c8dd05ca996ca7390..1b1aef2e79f138036c853f4159311f6559c25285 100644
--- a/src/testing/synctest/synctest.go
+++ b/src/testing/synctest/synctest.go
@@ -28,7 +28,10 @@ import (
 // goroutines are blocked and return after the bubble's clock has
 // advanced. See [Wait] for the specific definition of blocked.
 //
-// If every goroutine is blocked and there are no timers scheduled,
+// Time stops advancing when f returns.
+//
+// If every goroutine is blocked and either
+// no timers are scheduled or f has returned,
 // Run panics.
 //
 // Channels, time.Timers, and time.Tickers created within the bubble