You can do this entirely with bash 4.3 and above:
_timeout() { ( set +b; sleep "$1" & "${@:2}" & wait -n; r=$?; kill -9 `jobs -p`; exit $r; ) }
- Example: - _timeout 5 longrunning_command args
 
- Example: - { _timeout 5 producer || echo KABOOM $?; } | consumer
 
- Example: - producer | { _timeout 5 consumer1; consumer2; }
 
- Example: - { while date; do sleep .3; done; } | _timeout 5 cat | less
 
- Needs Bash 4.3 for - wait -n
 
- Gives 137 if the command was killed, else the return value of the command. 
- Works for pipes.  (You do not need to go foreground here!) 
- Works with internal shell commands or functions, too. 
- Runs in a subshell, so no variable export into the current shell, sorry. 
If you do not need the return code, this can be made even simpler:
_timeout() { ( set +b; sleep "$1" & "${@:2}" & wait -n; kill -9 `jobs -p`; ) }
Notes:
- Strictly speaking you do not need the - ;in- ; ), however it makes thing more consistent to the- ; }-case.  And the- set +bprobably can be left away, too, but better safe than sorry.
 
- Except for - --forground(probably) you can implement all variants- timeoutsupports.- --preserve-statusis a bit difficult, though.  This is left as an exercise for the reader ;)
 
This recipe can be used "naturally" in the shell (as natural as for flock fd):
(
set +b
sleep 20 &
{
YOUR SHELL CODE HERE
} &
wait -n
kill `jobs -p`
)
However, as explained above, you cannot re-export environment variables into the enclosing shell this way naturally.
Edit:
Real world example: Time out __git_ps1 in case it takes too long (for things like slow SSHFS-Links):
eval "__orig$(declare -f __git_ps1)" && __git_ps1() { ( git() { _timeout 0.3 /usr/bin/git "$@"; }; _timeout 0.3 __orig__git_ps1 "$@"; ) }
Edit2: Bugfix.  I noticed that exit 137 is not needed and makes _timeout unreliable at the same time.
Edit3: git is a die-hard, so it needs a double-trick to work satisfyingly.
Edit4: Forgot a _ in the first _timeout for the real world GIT example.
Update 2023-08-06:  I found a better way to restrict the runtime of git, so the above is just an example.
The following is no more bash-only as it needs setsid.  But I found no way to reliably create process group leaders with just bash idioms, sorry.
This recipe is a bit more difficult to use, but very effective, as it not only kills the child, it also kills everything the child places in the same process group.
I now use following:
__git_ps1() { setsid -w /bin/bash -c 'sleep 1 & . /usr/lib/git-core/git-sh-prompt && __git_ps1 "$@" & wait -n; p=$(/usr/bin/ps --no-headers -opgrp $$) && [ $$ = ${p:-x} ] && /usr/bin/kill -9 0; echo "PGRP mismatch $$ $p" >&2' bash "$@"; }
What it does:
- setsid -w /bin/bash -c 'SCRIPT' bash "$@"runs- SCRIPTin a new process group
- sleep 1 &sets the timeout
- . /usr/lib/git-core/git-sh-prompt && __git_ps1 "$@" &runs the git prompt in parallel- 
- /usr/lib/git-core/git-sh-promptis for Ubuntu 22.04, change it if needed
 
- wait -n;waits for either the- sleepor- __git_ps1to return
- p=$(/usr/bin/ps --no-headers -opgrp $$) && [ $$ = ${p:-x} ] &&is just a safeguard to check- setsidworked and we are really a process group leader- 
- $$works here correctly, as we are within single quotes
 
- kill -9 0unconditionally kills the entire process group- 
- all gitthat may still execute
- including the /bin/bash
 
- echo "PGRP mismatch $$ $p" >&2'is never reached- 
- This informs you that either setsidis a fake
- or something else (kill?) did not work as expected
 
The safeguard protects against the case that setsid does not work as advertised.  Without your current shell might get killed, which would make it impossible to spawn an interactive shell.
If you use the recipe and trust setsid, you probably do not need the safeguard, so setsid is the only non-bash-idiom this needs.