Skip to content

Docs: state the 'builtins must not block a runtime worker' invariant where builtin authors will see it #10

Description

@cataggar

Summary

Cleanup / documentation. #5 fixed a deadlock caused by a builtin blocking on a descriptor while occupying a tokio worker. The invariant that prevents a recurrence is currently written down in only one place — the doc comment on openfiles::without_parking_worker — which is a file a builtin author has no particular reason to open.

Proposal: state it on builtins::Command::execute in brush-core/src/builtins.rs, since that is the thing someone reads when writing a new builtin.

The invariant

A builtin runs inline on the task that invoked it, and for the common case that task is a runtime worker:

  • interp.rs sets run_in_current_shell = pipeline_len == 1 || ...
  • a single-command pipeline therefore takes ShellForCommand::ParentShell
  • which dispatches to execute_via_builtin_in_parent_shell, awaiting the builtin directly rather than handing it to a thread

So a builtin that performs a blocking read occupies a worker for the duration. That is fine when the data is already there, and deadlocks when the thing that would produce the data is a task sitting in that same worker's LIFO slot — which is exactly what mapfile -t x < <(cmd) inside a command substitution produces, because the process-substitution producer is spawned from that worker and the LIFO slot is not stealable by its peers.

Note this is the common path, not the exceptional one. execute_via_builtin_in_owned_shell (multi-command pipelines) does use spawn_blocking and is safe, but a plain foo < <(bar) is a single-command pipeline.

Not an instance: bundled coreutils

Worth recording so the next person does not have to re-derive it. The experimental-bundled-coreutils builtins look like they should be affected — with the feature on, type cat reports cat is a shell builtin — but they are not, because they do not run in-process. The shims re-execute the brush binary as a child process, as the crate docs describe:

$ brush -c 'echo "shell pid: $$"; cat /proc/self/status | grep -E "^(Name|Pid):"'
shell pid: 402965
Name:   brush
Pid:    402983      # separate process

The shell awaits a child and yields, so no worker is held. Confirmed empirically that cat, head, sort and wc all complete normally in the shape that hung mapfile.

Suggested wording

Something to the effect of: a builtin implementation must not block indefinitely on a file descriptor; it runs on a runtime worker and blocking there can prevent a process-substitution producer from ever being polled. Use openfiles::without_parking_worker around any synchronous read that can wait on a pipe.

After #5 the two builtins that need it (mapfile/readarray and read) already use it, so this is purely about the next one.

Optionally

A debug assertion — or a #[cfg(debug_assertions)] warning — when a builtin returns after having blocked on the current worker for longer than some threshold would catch it at the point of introduction rather than as a hang in someone's script. Lower priority than the doc note and more intrusive, so mentioning rather than proposing.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions