Skip to content
Shell & TerminalDeep Dive Published Updated 8 min readViews unavailable

Fish Event Handlers: Registration, Delivery, and Reliable Lifecycle Boundaries

Use Fish event handlers for prompt hooks, variable notifications, and job cleanup while respecting load timing, coalescing, event blocks, and process boundaries.

Fish event handlers are functions registered to run when Fish emits a named event, observes a variable change, receives a signal, or tracks a job or child-process exit. They are useful for interactive integrations and lightweight cleanup, but they are notifications inside one Fish process, not a durable event queue or an inter-process messaging bus.

The most important operational details are easy to miss: a handler is active only after its function has been loaded; variable notifications have no per-assignment delivery guarantee; multiple handlers for the same event run in unspecified order; and disowning a job removes the information needed for Fish to notify exit handlers. Design handlers as short, idempotent reactions, and use explicit IPC or persistent storage when every event matters.

Register a named event and understand its arguments

Use function --on-event NAME to register a function for a named event. emit NAME ... delivers a custom event in the current Fish process, and the arguments after the event name become the handler’s function arguments in $argv.

function report_build_finished --on-event build_finished
    set -l build_id $argv[1]
    set -l result $argv[2]
    printf 'build %s finished: %s\n' $build_id $result
end

emit build_finished release-42 passed

Keep the event name and argument schema stable within the application. A handler should validate argument count and content before using them. Event arguments do not become a durable record; if the receiver is not active or the shell exits, Fish does not provide a replay mechanism. Use a file, database, message broker, or another explicit transport if a downstream process must receive the event even when it starts later.

Fish also emits built-in interactive events such as fish_preexec, fish_postexec, fish_posterror, fish_prompt, fish_exit, and terminal focus events. These are useful for prompt integrations, timing displays, and local instrumentation. They are not a substitute for system-wide audit logs: events are local to the Fish process and interactive events do not necessarily occur in scripts or empty command lines.

Load event functions before expecting them to run

Fish autoloads ordinary functions when their name is first used, but it cannot load a function merely because an event that function listens for has occurred. The handler must already be defined in the running process. Put always-needed event registrations in config.fish or explicitly source the file that defines them before triggering the event.

# ~/.config/fish/conf.d/local-build-events.fish
function log_build_event --on-event build_finished
    printf 'build event: %s\n' $argv
end

For an app-specific configuration, source the handler file in the same Fish session that emits the event. Do not assume that placing a function under functions/ is enough to register an event handler before first invocation. During debugging, compare functions --details FUNCTION_NAME and the result of a deliberate emit in the same shell.

If multiple handlers subscribe to one event, Fish runs all of them but does not guarantee their order across releases. Each handler should therefore be independent and should not expect another handler to initialize data first. If ordering is a requirement, create one orchestrating function that invokes the steps explicitly in sequence and emits a single event after the state is ready.

Treat variable handlers as coalesced notifications

function --on-variable NAME runs when Fish observes that the variable has been set, but Fish explicitly makes no guarantee about precise timing or one callback for every set. Intermediate values may be skipped, and setting the same value may still trigger a callback in some cases. For a universal variable changed in another Fish instance, only changes in its value are picked up.

That contract is suitable for refreshing a derived display from the current value. It is not suitable for counting every update, implementing a transactional observer, or reconstructing a sequence of changes. Read the variable’s current state when the handler runs instead of assuming the handler received a complete change record.

set -g app_theme light

function refresh_theme_cache --on-variable app_theme
    # Recompute a display cache from the current value.
    # This handler deliberately does not count changes.
    set -l current_theme $app_theme
    printf 'theme now: %s\n' $current_theme
end

set app_theme dark

Avoid writing the watched variable from inside its own handler unless the operation is intentionally guarded. Recursive updates can trigger more notifications. Fish provides set --no-event to suppress the variable-change event for a specific set or erase operation, but the manual recommends using it carefully because handlers may exist for a reason. A safer default is to write a separate derived variable, compare before updating, and make the handler idempotent.

If every transition matters, write each transition to an explicit append-only log or send it through a process-safe IPC mechanism at the point where the producer knows the change occurred. A variable watcher is an optimization for reacting to current state, not an event-sourcing architecture.

Choose job-exit and process-exit events precisely

--on-job-exit PID fires when the job containing a child process with the given PID exits. --on-process-exit PID fires when a Fish child process with that process ID exits. These are different scopes: a job can contain a pipeline, while a process is one child. Fish’s documentation also notes that disowned jobs no longer generate these notifications because Fish has dropped its knowledge of them.

Use a job-exit handler for temporary interactive cleanup or a notification tied to work the current shell launched. Do not rely on it to monitor a daemon after calling disown, after Fish exits, or from another Fish instance. Do not use a guessed PID from a stale job listing; identify the intended running child and register the handler while Fish still tracks the job.

function notify_when_job_finishes
    set -l job (jobs -l -g)
    or begin
        printf '%s\n' 'no tracked job is available' >&2
        return 1
    end

    function _notify_job_$job --on-job-exit $job --inherit-variable job
        printf '\a'
        functions --erase _notify_job_$job
    end
end

This follows Fish’s documented notification pattern: create a handler associated with the selected job and snapshot the identifier with --inherit-variable. Validate the exact job identifier and test behavior when the job exits quickly. For service supervision, use a service manager; a shell event handler is tied to the lifetime and job table of that one interactive shell.

Understand signal delivery and exit semantics

Fish can define --on-signal handlers, but the signal must reach the Fish process. For example, Ctrl-C while a foreground command is running usually targets the foreground process group, not necessarily the waiting shell. Observing a signal also changes the default behavior: Fish’s docs say that registering a handler prevents Fish from exiting in response to that signal. A handler should therefore decide whether and how the shell continues, rather than merely logging and assuming default termination still happens.

For normal shell shutdown, use the built-in fish_exit event when local cleanup is appropriate. Cleanup should be bounded and best-effort. Do not block shell shutdown on remote calls, and do not expect cleanup to run after an uncatchable termination, power failure, or process crash. Durable state should be written before shutdown rather than delegated to an exit callback.

Use event blocks as deferral, not as locking

block delays event delivery until it is removed. A local block is released when its scope ends; a global block must be explicitly erased. This can prevent a handler from observing partially updated interactive state while a group of commands changes it. It is not a lock against other processes, and the queued notifications are not a transaction log.

function update_prompt_state
    block --local
    set -g prompt_mode compact
    set -g prompt_color blue
    set -g prompt_ready yes
end

When the local block ends, delayed events may run against the final state. Keep the scope short so unrelated handlers are not delayed longer than intended. Avoid block --global in startup snippets or recovery-sensitive code unless the same code owns a guaranteed block --erase path; a forgotten global block can make an interactive shell appear unresponsive to events.

Keep handlers short and observable

Handlers execute inside Fish and can affect prompt latency, variable state, and job control. Avoid running network calls or unbounded loops in a prompt event. If a handler starts a process, define how it is stopped and how duplicate starts are detected. If it mutates a watched variable, include a guard or use the carefully scoped --no-event option for derived writes.

For diagnostics, use Fish’s debug categories or add a concise timestamp and event name to a controlled log. Do not print every variable change to the user’s terminal in production; that can corrupt command output and make interactive behavior harder to reproduce. A handler that runs before each prompt should do work proportional to the immediate prompt requirement, not poll a full service inventory on every command.

Acceptance checks for an event integration

Test the handler after a fresh shell start, after autoloading-related changes, and in a separate Fish process. Verify that named-event arguments arrive as expected, the handler order is irrelevant, repeated variable sets do not corrupt state, and the watched value can change several times before the handler runs. Test job completion before and after disowning, a process that exits immediately, a handled signal, and ordinary fish_exit cleanup.

Confirm that no correctness requirement depends on a handler running exactly once or on a particular ordering. If the requirement includes every change, cross-process delivery, replay, or durability, replace the event callback with a transport that guarantees those properties. Keep Fish handlers as local reactions to shell state and user-interface lifecycle, and the event model remains predictable.

Related:

Sources:

Comments