OK, I think I have it. It's ugly.
Firstly, note that multiple instances of doCall can be running for the same key. This happens when:
1. you invoke DoChan. This inserts a 'c' (call struct) into the map and starts doCall in a goroutine.
2. at this point it's not shared: i.e. you don't call DoChan again with the same key (yet).
3. you invoke ForgetUnshared on this key. This "detaches" it, but doCall carries on running. It has its own local copy of 'c' so it knows where to send the result, even though the map is now empty.
4. you invoke DoChan again with the same key. This inserts a new 'c' into the map and starts a new doCall goroutine.
At this point, you have two instances of doCall running, and the map is pointing at the second one.
This is where it gets ugly.
5. you invoke DoChan yet again with the same key. This turns it into a shared task, with c.dups > 0, len(c.chans) > 1.
6. the first instance of doCall terminates. At this point it unconditionally removes the key from the map - even though it had previously been removed by ForgetUnshared!
func (g *Group) doCall(c *call, key string, fn func() (interface{}, error)) {
c.val, c.err = fn()
c.wg.Done()
g.mu.Lock()
delete(g.m, key) // <<<< NOTE
for _, ch := range c.chans {
ch <- Result{c.val, c.err, c.dups > 0}
}
g.mu.Unlock()
}
So, even though it's the first instance of doCall which is terminating, it's removing the second instance of doCall from the map. This is now also a detached task.
7. In one of the two goroutines, the timeout event occurs. It calls ForgetUnshared, which happily returns true because the key does not exist in the map - and therefore you proceed to cancel the context.
But actually a task with this key *is* running; and furthermore, it is a shared task, with 2 channel receivers.
8. Once the sleep has completed in the task function, it notices that the context is cancelled and returns an error.
9. doCall sends the resulting error down multiple channels (those you started in steps 4 and 5 above)
10. The select { case res := <-ch } triggers in the *other* goroutine - the one which didn't have a timeout. Hence it receives the error, and that's where you panic().