Skip to content

interp: implement the job control builtins (jobs, kill, disown, fg, bg) - #1382

Closed
0pcom wants to merge 3 commits into
mvdan:masterfrom
0magnet:sh-job-control
Closed

0pcom wants to merge 3 commits into
mvdan:masterfrom
0magnet:sh-job-control

Conversation

@0pcom

@0pcom 0pcom commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

A job here is a goroutine running a subshell rather than a process, so the builtins are shaped by what that can honestly mean: jobs lists them, kill cancels a job's context (which is what any fatal signal would have amounted to), disown drops it from the table and out of a bare wait, fg waits for the job since there is no terminal to hand it, and bg reports on a job that is already running. Nothing is ever stopped, so SIGSTOP/SIGCONT are rejected rather than faked. This is also what makes them work on js/wasm, where there are neither processes nor signals.

The second commit detaches background jobs from the statement's context, because a job outlives the command line that started it: an interactive shell cancels a line's context when the line is done, and in bash the interrupt that ends a foreground job leaves the background ones running. What ends a job is kill, the shell exiting, or the new Runner.StopJobs.

help gains a small notes table for builtins that cannot mean quite the same thing without processes or a controlling terminal.

Split out of the earlier version of this PR as requested: umask is #1411 and enable/compgen/history/logout are #1412, each independent against master.

Part of #1401.

@0pcom

0pcom commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Rebased on the reworked #1381 — this PR is stacked on it, so its first three commits are that one.

I applied your #1381 review here too, without waiting to be told twice:

  • Three files carried a Copyright (c) 2026, 0magnet fork authors header. Replaced with the project's. Same class of thing as the times comment you flagged: fork context that has no business upstream.
  • helpNames is gone, so the two call sites in bashbuiltin.go now use slices.Sorted(maps.Keys(helpTable)) inline.
  • The help test cases in runTests are updated for this PR's effect: implementing job control moves jobs from starred-unimplemented to implemented-with-a-note, so ulimit becomes the starred example instead.

One thing I did not do, and would rather ask than guess. jobs_test.go is still a unit test file. Your "we avoid unit tests where possible" applied cleanly to help_test.go — those were exact-output checks that belong in runTests, and folding them in was strictly better, since TestRunnerRunConfirm then validated them against real bash.

These do not convert as cleanly. Of the 41 cases, most assert with strings.Contains rather than exact output, because job state and the order two background shells finish in are not fixed — sleep 30 & jobs has to tolerate whatever else is on the line. And nearly every one would need #IGNORE, since bash's job control genuinely differs here: a job is a goroutine, nothing is ever stopped, and there is no controlling terminal. A table where every entry is ignored seems worse than a unit test that says why.

So jobs_test.go keeps its own small runSrc helper, with a comment explaining the choice. Happy to convert the subset that is exact-output if you would prefer that split, or to convert the lot — say which.

go test ./interp/ passes, bash confirmation included.

@0pcom

0pcom commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Filed #1401 covering the unimplemented builtins, per the contributing guidelines, to accompany this and #1381.

@0pcom
0pcom force-pushed the sh-job-control branch 3 times, most recently from ea0361e to a7c9fc8 Compare August 24, 2026 18:19
@0pcom
0pcom force-pushed the sh-job-control branch 2 times, most recently from 8ab0f51 to 0b39dd0 Compare September 1, 2026 18:26
@0pcom

0pcom commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Rebased on current master. The interesting merge was with the new $! handling from master: bgProc now carries both the fake-PID/started-report fields from master and this PR's job-control fields, background statements get both the started-program report and the detached cancellable context, and the wait builtin uses master's lookupBgProc together with the interruptible await from this PR.

@mvdan

mvdan commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Thanks - please split this again, as it seems to me like job control has little to do with some of the non-trivial builtins you are adding here, like compgen and history. Ideally I don't want to be reviewing more than a few hundred lines per PR.

@0pcom 0pcom changed the title interp: job control (jobs/fg/bg/kill/disown), enable, compgen, history — and detach background jobs from the statement context interp: implement the job control builtins (jobs, kill, disown, fg, bg) Sep 3, 2026
@0pcom

0pcom commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Done — split three ways, each independent against master:

The one seam between them: compgen -A job and -A signal in #1412 yield nothing until this PR lands, noted there. Each branch passes go test ./... on its own.

@mvdan

mvdan commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Thanks for splitting it. Four things before I look closer:

kill $! doesn't work. Since the $! change on master, $! is the real PID whenever the background statement is exactly one external program, but jobSpec only knows %n, %str and the gN ids:

$ gosh -c 'sleep 30 & echo pid=$!; kill $!; echo st=$?; jobs'
pid=65252
kill: 65252: no such job
st=1
[1]+  Running                 sleep 30

That's the common form in scripts. Inverted, too: a bare kill 1 cancels job 1, where bash signals PID 1. The id/PID forms should go through lookupBgProc, and bare integers should mean PIDs.

Detaching from the caller's context changes behavior for every embedder, not just interactive ones. Running sleep 5 & echo started under a 100ms context: master reaps the child on cancel, this branch leaves it running. Anyone bounding a script with a timeout now leaks processes and goroutines. And Reset clears bgProcs, so after a reset those jobs are both unreachable and unstoppable — their cancel funcs are gone. I'd want this gated on Interactive(true) or an explicit option, with Reset stopping jobs, and I'd rather review it as its own PR: it's the risky part, the builtins are the easy part.

StopJobs hangs. It waits on every bgProc, including the process-substitution shells, whose cancel is nil. echo <(sleep 20) >/dev/null then StopJobs() blocks for the full 20s, which isn't what its doc promises. It also takes no context, so a caller can't bound the wait — for frozen v3 API I'd want StopJobs(ctx). No in-repo caller either; cmd/gosh is the motivating case and isn't updated.

The tests leak. go test -run TestDisownFgBg ./interp leaves four sleep 30 processes alive after the binary exits — the disown cases never kill their jobs and nothing calls StopJobs. runSrc also shares one strings.Builder between stdout and stderr and reads it while detached job goroutines can still write; you already wrote syncBuffer for the last test, so -race only stays quiet because sleep prints nothing.

On the unit-test question: split it. The deterministic cases (kill -l 9, kill -l TERM, the invalid-option and invalid-signal errors, sleep 0 & wait; jobs, (exit 4) & fg, disown; jobs, bg on a finished job) have fixed output and belong in runTests; #IGNORE on most of them is fine. Keep a Go test only for what's genuinely concurrent.

@0pcom

0pcom commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

All four addressed. The context detachment is now its own PR — #1418, stacked on this branch — and this PR is builtins-only again.

kill $! and bare integers. jobSpec now treats anything without a leading % as a PID, resolved backwards through the job ids the way lookupBgProc does — the real PID when the statement was exactly one external program, gN otherwise — so kill $! works in both shapes, and a disowned job's PID still resolves, since bash's kill also still reaches a disowned job's process. Your repro:

$ gosh -c 'sleep 30 & echo pid=$!; kill $!; echo st=$?; wait; jobs'
pid=2740006
st=0
[1]+  Terminated              sleep 30

A bare integer that matches no job is signalled as a real process where the platform can (unix; an error on windows/plan9/js): before this PR those specs reached the system's kill program, and kill $(cat pidfile) shouldn't regress just because kill became a builtin. The inverted case:

$ gosh -c 'sleep 30 & kill 1; echo st=$?; jobs; kill %1; wait'
kill: (1) - operation not permitted
st=1
[1]+  Running                 sleep 30

jobs -p and jobs -l print the same ids $! reports, so they also show real PIDs now.

Detachment — moved to #1418, gated on Interactive(true), with Reset stopping jobs first; details and tests there.

StopJobs — also #1418: it takes a context so the wait can be bounded, skips the process-substitution shells (echo <(sleep 20) >/dev/null no longer blocks it), and cmd/gosh calls it when the interactive loop exits.

Tests — the deterministic cases moved into runTests (kill -l 9 and kill -l TERM confirm against bash; the rest carry #IGNORE), and the Go tests keep only genuinely concurrent state. Every script that starts a sleep now ends it — kill + wait, by PID for the disowned ones — and runSrc writes through a locked buffer. go test -race ./interp/ passes with no sleep processes left behind.

@mvdan

mvdan commented Sep 8, 2026

Copy link
Copy Markdown
Owner

kill $! and kill 1 behave as you pasted. Non-interactive jobs die with the caller's context again — sleep 5 & under a 100ms context reaps the child in 0.1s where the old branch took 5.0s. StopJobs(ctx) returns immediately on my echo <(sleep 20) repro. go test -race ./interp passes with no leftover sleeps, the confirm suite runs 1404 subtests green including the three new cases without #IGNORE, and gofmt/vet plus windows/plan9/js/386/darwin builds are clean. The test split is the right shape now.

One real bug left, and this revision made it matter more: signalNames hardcodes Linux's numbering, and killProcess passes those numbers straight to unix.Kill. On darwin SIGUSR1 is 30 and 10 is SIGBUS, so kill -USR1 <pid> on macOS sends SIGBUS to a real process. Same root cause makes effectOf switch on numbers (17–22), misclassifying signals off Linux, and kill -l print wrong names. Resolve names through the platform (unix.SignalNum) in os_unix.go; a fixed table only makes sense where there's nothing to ask.

Smaller, none blocking:

  • kill -STOP %1 is rejected as "no job control" while kill -STOP <pid> really stops a process — defensible, wants a comment.
  • kill -0 on a finished job succeeds; bash errors.
  • fg returns 130 on cancellation where the rest of interp goes through r.exit.fatal(ctx.Err()).
  • Finished jobs never leave the table, so jobs reprints Done forever and job numbers only grow; bash drops a completed job once reported.
  • bg.cmd == "" doubles as "not a real job" in several places; an explicit field would read better.
  • In interp: detach an interactive runner's jobs from the statement's context #1418, Reset can now block — bounded by the exec handler's kill timeout, but new for a method with no context, so worth a doc line.
  • os_notunix.go says "plan9 and windows"; it also covers js/wasm.

Finally, kill now signals arbitrary host PIDs from the interp process, which ExecHandler no longer sees. CallHandler does run for builtins, so embedders can still intercept it - worth naming that in the docs.

@0pcom

0pcom commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Signal names now resolve through the platform: signalName/signalNum in os_unix.go ask x/sys/unix, effectOf switches on the name, and kill -l walks the host's own numbering. The table survives only in os_notunix.go, where there is nothing to ask and the names never reach a kernel.

One correction to the diagnosis, because I went looking for the misclassification and it is not where either of us thought. On darwin — and on FreeBSD, OpenBSD, NetBSD and DragonFly — 17 through 22 are exactly the six job-control signals in a different order, so the old range was accidentally correct there. The platform it actually broke on is solaris, where 17 is USR2 and 19–22 are PWR, WINCH, URG and IO: five signals that should end a job, refused as "no job control". The kill -USR1 fault you described is real and unaffected by this — that is the killProcess half, and 10 is BUS on darwin as you said.

I cannot test darwin or solaris here, only Linux. The new tests are written entirely in terms of names and mention no number, so they assert the same thing on every platform and would have failed against the old table — but they have only been run on Linux. kill -l output and the named lookups match bash here; go test -race ./interp is clean with no leftover sleeps.

The rest:

  • kill -0 on a finished job now fails with "no such job", as bash does.
  • fg goes through exit.fatal(ctx.Err()) on cancellation instead of returning 130.
  • Process-substitution shells carry an explicit substitution field; every cmd == "" test was really asking that.
  • kill -STOP on a job is still refused while a PID really stops — comment added saying why.
  • CallHandlerFunc documents that it is the only handler which sees kill.
  • os_notunix.go comments now mention js/wasm.

Two left undone, deliberately. Finished jobs still never leave the table: matching bash means reusing job numbers, which renumbers %n and destabilises $! for jobs already reported, and I would rather have your call on that than guess. And Reset's new blocking behaviour is #1418's, so the doc line belongs there — I will add it in that PR.

@0pcom

0pcom commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

CI caught that my kill -0 change was wrong, and I have reverted it in 76ff8b6.

bash does report "no such job", but not for the reason I implemented. Its rule is about whether the job has been reaped, not whether it is still running — a finished job that nothing has reported still answers:

$ true & kill -0 $!; echo st=$?
st=0
$ true & wait; kill -0 %1; echo st=$?
bash: kill: %1: no such job
st=1

Testing running() therefore made the answer depend on whether the background job had got there first, which turned true & kill -0 $! — an existing case in TestRunnerRun — into a race. It passed here and on macos/1.26, and failed on ubuntu, windows and macos/1.27. My local full run passed too, which is exactly the false confidence a timing-dependent change gives you.

So this is not a separate smaller item: kill -0 cannot be right until a job can be reaped, which makes it the same question as finished jobs never leaving the table. Both are waiting on your call about renumbering. I have left the old behaviour with a comment recording why.

The signal-name work is unaffected — that is the earlier commit and it was green on macos/1.26. Reverting only the kill -0 hunk; everything else stands.

@0pcom

0pcom commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Green apart from one unrelated flake. On 76ff8b6: macos 1.26 and 1.27, ubuntu 1.26, windows 1.26 and alpine all pass; ubuntu 1.27 was cancelled by fail-fast rather than failing.

The one failure is TestParseConfirm/bash/OK/363-0 and 364-0 on windows 1.27 — inputs $"foo" and $"foo$", both shell terminated by signal: context deadline exceeded. That is syntax/ shelling out to a real bash and timing out. Both commits together touch seven files, all under interp/, and interp passed on that same runner. I cannot rerun the job without admin rights on the repo.

Worth noting what the green macos runs bought: the signal tests are written in terms of names and had only ever been run on Linux, so this is the first time the platform lookup was actually exercised on darwin, where USR1 is 30 rather than 10. It passes there on both Go versions.

@mvdan

mvdan commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Two things on the current revision.

kill can signal the host process itself. killProcess hands the PID straight to unix.Kill, and kill(2) reads non-positive PIDs specially — 0 is the caller's process group, -1 is everything the user can reach:

$ setsid gosh -c 'echo before; kill 0; echo survived'
before
exit=143

For gosh that matches bash. For an embedded interpreter it means a script can SIGTERM the host application and its whole process group, and per the new CallHandlerFunc doc ExecHandler never sees it.

The comment justifying the bare-PID path isn't right either:

before kill was a builtin these reached the system's kill program

On master they don't — kill 99999999 gives kill: unsupported builtin, exit 2. kill went into IsBuiltin in 26d6d05, released in v3.13.1; it reached /bin/kill only up to v3.13.0. So this isn't preserving what scripts had last release, it's a new capability that bypasses the handler which used to gate it.

I don't have a strong opinion about what the right behavior here is, but at the very least, the comment is wrong.

Process substitutions consume job numbers. byNumber indexes bgProcs directly, so a substitution shell occupies a slot and shifts every %n:

$ gosh -c 'cat <(echo hi) >/dev/null; sleep 30 & kill %1'
kill: %1: no such job

%2 works, but bash calls that job %1. The substitution field's own doc says these have no job number — they do, they're just skipped when listing. Job numbers should come off jobIndexes(). This affects spec resolution for kill, fg, bg, disown and jobs %n, not only the listing.

@0pcom

0pcom commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Both fixed in 40342216.

kill and non-positive PIDs. killProcess now refuses pid <= 0, and the comment states that instead of the claim you corrected. One correction back: 26d6d053 shipped in v3.13.0, not v3.13.1, so kill has not reached /bin/kill in either released version — which only strengthens your point. I went with refusing rather than forwarding, since 0 and -1 name the host's process group rather than anything this runner started; the cost is that gosh no longer matches bash on kill 0. Easy to change if you would rather it went through a handler instead.

Job numbers. Numbering now skips process substitutions. I did not take it off jobIndexes() directly, because that also drops disowned jobs, and bash keeps the numbers of the jobs after a disowned one:

$ bash -c 'sleep 30 & sleep 30 & disown %1; jobs'
[2]+  Running                 sleep 30 &

So jobNumber counts non-substitution entries and still counts disowned ones. jobs, kill, fg, bg and disown all resolve through it, so the listing and spec resolution cannot disagree. Both of your cases now match bash.

Two regression tests cover them. Against the unfixed code the first one kills the test binary outright, which is the bug.

@mvdan

mvdan commented Sep 19, 2026

Copy link
Copy Markdown
Owner

I'm not sure what to do with this feature, to be entirely honest. Every time I look at it, I uncover new bugs. We might need to carefully come up with a plan and design before looking at code. Or perhaps job control is not something that this library should aim to do, if it's not a good fit for Go or for the current v3 API.

  1. kill seems to support both jobs and PIDs as arguments. A script running e.g. kill 1 or kill 321 could severely affect the host, in a way that could be highly dangerous to existing users of the interpreter. We don't promise a full sandbox or isolation, but it seems like a security issue to let a Go library kill the host machine.

I think we should only allow killing by PID - or killing PIDs which we have not started ourselves - behind an opt-in flag, like #171. But for now I probably would just leave that as a TODO and just forbid it entirely.

  1. { { sleep 0.1; echo child-finished; } & } & wait; sleep 0.3 prints child-finished in master and in Bash, but not here.

  2. sleep 5 & p=$!; kill "$p"; wait "$p" ends with a context canceled error rather than a standard shell exit status error.

@0pcom

0pcom commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

You asked for a plan before more code, which is fair — the PR described what it
built and never said who needs it. Here it is, along with the bugs a proper
look turned up.

What uses this

An interactive shell in a browser tab: websh,
a terminal over interp on js/wasm with no processes anywhere. It cancels the
context at the end of every line, so & only works at all because a background
job is detached from the line that started it (#1418), and its completion menu
offers jobs, kill, disown, fg, bg because a user at a prompt expects
them. That is the whole motivation: an interactive front end needs to see and
end its own background work
, and on js/wasm there is no /bin/kill to fall
back to.

That also explains the shape. A job is a goroutine, so kill cancels a context,
fg waits instead of handing over a terminal, and stop/continue are refused
rather than faked. None of it pretends to be process job control.

What I found, and fixed in a1143009

Checking every case against bash 5.3 rather than against my own expectations:

  • Finished jobs were never reaped. bash drops a job once jobs or wait
    has reported it, which is why a second jobs prints nothing. They stayed
    here: listed forever, and the table grew without bound in a long-lived shell.
  • Numbering followed position in that table, so a new job after a finished
    one was [2] where bash says [1]. bash gives a new job the number after the
    highest one still listed, which is not the lowest free one.
  • A job finishing mid-listing could be reported as Running and reaped in the
    same breath
    , because the row read running() twice. It then vanished
    without anything ever reporting it had ended.
  • wait took only a PID, not a job specification.
  • Listings lacked the trailing & on a running job, and the status column was
    the wrong width.

$! now reads the most recent job directly, so it still answers after that job
has been reaped, as bash's does.

Two gaps left open, deliberately

  • A subshell sees no jobs. bash clears the table in an explicit ( … ) but
    keeps it in a pipeline element and a command substitution, so jobs | grep x
    and x=$(jobs) work there and print nothing here. Matching it is a decision
    about Runner.subshell, not about job control, so I have not made it.
  • Nothing is announced. bash's [2] Terminated lines come from its prompt
    loop. jobs -n is the piece of that a shell built on this package can call
    for itself.

Both are in the package comment so the next reader does not have to rediscover
them.

On whether it belongs here

I don't think it can be a separate package, and the reason is specific rather
than aesthetic: wait and $! are already yours and already part of job
control. disown changes what a bare wait waits for; reaping changes what
wait and $! can still find. The listing and jobspec parsing could live
outside interp given an exported view of the background table, but the state
those builtins read and write could not, and the state is where every bug above
was.

So the honest options are to take it as an interp feature, or to decide the
package does not want job control and leave wait and $! as the only things
that know about background jobs. I'd rather you pick than keep sending patches
at it — and if it is the second, say so and I'll close this and #1418 without
any hard feelings; the fork costs me little.

0pcom added a commit to 0magnet/sh that referenced this pull request Sep 28, 2026
The job table never shrank. A finished job stayed in it, was listed again
by every later jobs, and pushed the next job's number up; bash drops a job
once jobs or wait has reported it, which is why a second jobs prints
nothing and why the numbering starts again from one. For an interactive
shell embedding this — a browser terminal, in our case — the table also
grew for the life of the session.

Jobs now carry their number rather than deriving it from their position in
the table, and the table holds pointers so that dropping one leaves the
rest alone. $! reads the most recent job directly, so it still answers
after that job has been reaped.

This brings master in line with the sh-job-control branch, which is what
mvdan#1382 proposes, so the same code runs in both places. Three
behaviours change with it:

  - A bare integer in a job spec is a PID and never a job number, as in
    bash. `kill 1` no longer means job 1.
  - Signal names are resolved through the platform instead of a table
    compiled into the shell, which was Linux's numbering everywhere:
    SIGUSR1 is 10 on Linux and 30 on darwin, where 10 is SIGBUS.
  - wait takes a job specification as well as a PID.

Listings gain the trailing `&` on a running job and bash's column width,
and whether a job is running is read once per row: reading it twice let a
job finishing mid-listing be reported as Running and reaped in the same
breath, losing it before anything said it had ended.
0pcom added 3 commits October 1, 2026 10:27
jobs, kill, disown, fg and bg. A job here is a goroutine running a
subshell rather than a process, so the builtins are shaped by what that
can honestly mean: jobs lists them, kill cancels a job's context (which
is what any fatal signal would have amounted to), disown drops it from
the table and out of a bare wait, fg waits for it since there is no
terminal to hand it, and bg reports on a job that is already running.
Nothing is ever stopped, so SIGSTOP and SIGCONT are rejected rather
than faked. This is also what makes them work on js/wasm, where there
are neither processes nor signals.

Job specs follow bash: the % forms, plus whatever $! expanded to — the
real PID when the background statement was exactly one external
program, or the fake gN pid otherwise — so `kill $!` always works. A
bare integer is a PID, never a job number, and a PID that belongs to no
job is signalled as a process where the platform can, since scripts
reached the system's kill program before this builtin shadowed it.

Signal names resolve through the platform rather than a hardcoded
table: signalName and signalNum in os_unix.go ask x/sys/unix, effectOf
switches on the name, and kill -l walks the host's own numbering.

kill -0 reports on the job table only. bash's rule is about whether the
shell has reaped the process, which needs reaping this package does not
do, so implementing bash's answer here would have been a guess dressed
as a check.

help gains a notes table for builtins that cannot mean quite the same
thing without processes or a controlling terminal, and prints the note
after the description.
The job table never shrank. A finished job stayed in it, was listed again
by every later jobs, and pushed the next job's number up; bash drops a job
once jobs or wait has reported it, which is why a second jobs prints
nothing and why the numbering starts again from one. In a long-lived
interactive shell the table also grew without bound.

Jobs now carry their number rather than deriving it from their position,
and the table holds pointers so that dropping one leaves the rest alone.
$! reads the most recent job directly, so it still answers after that job
has been reaped, as bash does.

Two smaller fixes alongside: wait takes a job specification and not only a
PID, and a listing gets the trailing `&` on a running job and bash's column
width. Reading whether a job is running is now done once per row, since
reading it twice let a job finishing mid-listing be reported as Running
and reaped in the same breath.
A job ended by kill reported the subshell's own exit status, so
`sleep 5 & p=$!; kill "$p"; wait "$p"` answered 1 where bash answers
143. The jobs listing already said Terminated for that job, so the
two disagreed about the same job.

wait and fg now go through one place which gives a job kill ended
128 plus the signal number, as bash does, and the status a job which
finished on its own had.
@0pcom

0pcom commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

All three of the things you raised are fixed, and the design question is answered below.

Jobs no longer die with the statement that started them. { { sleep 0.1; echo child-finished; } & } & wait; sleep 0.3 printed nothing because the outer job cancelled its context when its own goroutine returned, and the inner job descended from it. A job's context now comes from the shell rather than from the statement, so nesting is flat. A non-interactive runner still bounds its jobs by the Run call, so an embedder with a timeout reaps every job including nested ones. This also made #1418 smaller, because detaching an interactive shell's jobs is now one decision about that context instead of a change to how every background statement runs.

kill can only signal this runner's own jobs. A PID it did not start is refused with "no such job", and there is a TODO pointing at #171. killProcess is gone and nothing reaches unix.Kill, so the builtin has no way to touch the host or its process group. kill $! still works in both of its shapes, since $! names a job. This is a capability removal rather than a fix, and it takes a whole class of bug with it, including the darwin and solaris numbering I could not test.

wait on a killed job reports 128 plus the signal. It answered 1 where bash answers 143, while the jobs listing already said Terminated for the same job. wait and fg now agree with the listing and with bash.

On whether this belongs here. websh is an interactive shell in a browser tab built on this package, with no processes anywhere, and it runs jobs -n after every line to report what finished. That is the whole motivation. A user at a prompt expects to see and end their own background work, and on js/wasm there is no /bin/kill to fall back to.

One thing worth saying plainly, because it bounds what this can ever promise. kill cancels a context. A job whose body watches its context stops, and one which does not keeps running and keeps being listed. The test suite here shows it, since its sleep is a Go function that ignores the context it is given, which is why two of my table cases no longer wait after killing. That is also every embedder's situation, because a command behind a CallHandler is a Go function. So this is job control over cooperative work rather than over processes, which is why fg and wait are interruptible and why stop and continue are refused instead of faked. I would rather document that than keep reaching for bash parity it cannot have.

Three commits, each building and passing on its own. If you would rather the package did not carry this, say so and I will close this and #1418.

0pcom added a commit to 0magnet/sh that referenced this pull request Oct 4, 2026
Take the versions under review upstream (mvdan#1382, mvdan#1418, mvdan#1412, mvdan#1411),
including wait and fg reporting 128 plus the signal. kill now signals
only this shell's own jobs and StopJobs takes a context, as in the PRs.

Keep what only the fork had: the umask applied to redirections, compgen
-A job and -A signal, the history and logout help notes, and the tinygo
SameFile shim.
qiangli added a commit to qiangli/sh that referenced this pull request Oct 5, 2026
The PowerShell resolver selects a private .NET runtime and its shared libraries. Keep those child controls in the fence environment snapshot so the same Bash# source runs in the scratch image.

Sprint: mvdan#358
Story: mvdan#1382
Story-ID: ff9c0f723cbf
mvdan added a commit that referenced this pull request Oct 20, 2026
A job is a background shell running as a goroutine rather than a
process, so the job control builtins are kept to what that can mean:

* jobs lists the jobs in Bash's format, with -p and job specs.
  A finished job is reported once and then dropped from the listing.
* kill cancels a job's context, with any of the signals whose numbers
  POSIX fixes: HUP, INT, QUIT, ABRT, KILL, ALRM, and TERM.
  A killed job exits with 128 plus the signal number, as in Bash.
  kill -0 checks whether a job is still running, and kill -l works.
* disown, with -a or job specs, drops jobs from the listing and from
  a plain wait.
* wait also accepts job specs.
* fg and bg fail with "no job control", like a non-interactive Bash,
  as nothing is ever stopped.

Job specs are %N, %%, %+, %-, %prefix, or what $! expanded to, be it a
fake PID or the real PID of an external program. Process substitutions
do not take up job numbers. Nested jobs keep running once the job which
started them finishes, and jobs still end with the context given to Run.
Reset leaves jobs running like a shell which exited, now documented.

Known differences with Bash:

* kill only signals this runner's own jobs. Other PIDs, including 0
  and -1, are "no such job", as signalling the host process or
  arbitrary processes from a library is dangerous; see #171.
* Only the signals above are supported, and all of them cancel the job
  the same way. A job ignoring its context keeps running, and a job
  running a program is ended via the ExecHandler, not by the signal.
  STOP, CONT, and the other signals are invalid.
* Job numbers are never reused, where Bash restarts from the highest
  number still listed.
* Killing a job also kills the jobs it started.
* A finished job is reported as Terminated if its exit status is
  128 plus a supported signal number, even if it exited on its own.
* jobs supports no options other than -p, disown has no -h nor -r,
  and %?str is not supported.
* Subshells such as pipeline elements see no jobs, and nothing
  announces finished jobs at a prompt.

Thanks to Moses Narrow for contributing the original implementation
in #1382, which this design is based on.

For #1401.
@mvdan

mvdan commented Oct 20, 2026

Copy link
Copy Markdown
Owner

For the sake of unblocking this work, I felt like it was best that I use this PR as the basis for a new implementation that only aims to support the basics in terms of what I think users and scripts actually use most.

c2cf6f2 is now in master with this; it's ~300 lines as opposed to the ~1300 here. I've attributed you in the commit message as well. Please take a look and let me know what you think; if there are features you're missing from that implementation, let me know and I can take a look. It's probably best to discuss in an issue rather than send patches, especially for large stuff like this PR.

I'll go through your PRs one by one, so it's probably best that we finish with this one before we look at the rest.

@0pcom

0pcom commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for taking this on and for the credit. The smaller design reads well, and the builtins cover what scripts use. I ran it against our browser shell and filed the two gaps it hits as #1440. I am closing this PR since master supersedes it.

@0pcom 0pcom closed this Oct 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants