hashicorp/nomad · warning

process did not exit after 15 seconds

Error message

process did not exit after 15 seconds

What it means

After sending the shutdown signal, Shutdown waits up to 15 seconds on the processExited channel. If the child has not exited by then, the executor logs a warning and appends this error to the shutdown multi-error, signaling the task ignored the kill signal and may need forced cleanup (e.g. cgroup kill).

Source

Thrown at drivers/shared/executor/executor.go:676

		case <-e.processExited:
		case <-time.After(grace):
			proc.Kill()
		}
	} else {
		proc.Kill()
	}

	// Issue sigkill to the process group (if possible)
	if err = e.killProcessTree(proc); err != nil {
		e.logger.Warn("failed to shutdown process group", "pid", proc.Pid, "error", err)
	}

	// Wait for process to exit
	select {
	case <-e.processExited:
	case <-time.After(time.Second * 15):
		e.logger.Warn("process did not exit after 15 seconds")
		merr.Errors = append(merr.Errors, fmt.Errorf("process did not exit after 15 seconds"))
	}

	if err = merr.ErrorOrNil(); err != nil {
		// Note that proclib in the TR shutdown may also dispatch a final platform
		// cleanup technique (e.g. cgroup kill), but if we get to the point where
		// that matters the Task was doing something naughty.
		e.logger.Warn("failed to shutdown due to some error", "error", err.Error())
		return err
	}

	return nil
}

// Signal sends the passed signal to the task
func (e *UniversalExecutor) Signal(s os.Signal) error {
	if e.childCmd.Process == nil {
		return fmt.Errorf("Task not yet run")
	}

View on GitHub (pinned to 482b49bf1a)

Solutions

  1. Configure a signal the app actually handles (e.g. kill_signal = "SIGTERM") so graceful shutdown works
  2. Fix the application to exit promptly on the shutdown signal
  3. Rely on the subsequent forceful cleanup (cgroup kill / Kill) that follows this timeout
  4. Increase application-level shutdown timeout handling rather than expecting the 15s wait to extend

Example fix

// before (task config)
# no kill_signal -> defaults to SIGINT which the app ignores
// after
kill_signal = "SIGTERM"
Defensive patterns

Strategy: fallback

Validate before calling

// Prefer a signal the application handles
cfg := task.Config
if cfg.KillSignal == "" {
    cfg.KillSignal = "SIGTERM" // only if app handles SIGTERM better than SIGINT
}

Type guard

func handlesSignal(appCmd []string, sig string) bool {
    // out of band: inspect app docs/config; here just confirm signal is valid
    _, ok := signals.SignalLookup[sig]
    return ok
}

Try / catch

if err := exec.Shutdown(); err != nil {
    if strings.Contains(err.Error(), "did not exit after 15 seconds") {
        log.Warn("graceful shutdown timed out; escalating to force kill")
        _ = exec.Kill() // fallback to hard kill / cgroup cleanup
        return nil
    }
    return err
}

Prevention

When it happens

Trigger: Calling Shutdown on a task that traps/ignores SIGINT (the default signal) and does not exit within 15 seconds; a hung or deadlocked child process; a child that spawned grandchildren keeping the process group alive.

Common situations: Applications that handle SIGINT interactively (shells, REPLs); zombie or stuck workers; tasks with long-running shutdown hooks exceeding the 15s window.

Related errors


AI-assisted analysis of hashicorp/nomad@482b49bf1a (2026-09-04). Data as JSON: /api/errors/4631af4adbeb3574. Report an issue: GitHub.