Skip to content

The lifecycle

Control when a plugin starts and stops with init(), ready(), and dispose(), and what happens when one of them fails.

A plugin's setup often depends on timing. A database has to connect before the first handler reads it, a status update needs a logged-in client, and a timer should stop before the process exits. Code in a constructor runs too early for all three.

seedcord calls your plugin at those points instead. init() is the one method every plugin writes. If you add ready() or dispose(), seedcord calls those too.

src/plugins/Presence.ts
import { Plugin } from '@seedcord/gateway';

export class Presence extends Plugin {
    private timer?: NodeJS.Timeout;

    public async init(): Promise<void> {
        this.logger.info('presence plugin starting');
    }

    public override async ready(): Promise<void> {
        this.timer = setInterval(() => {
            this.core.bot.client.user?.setActivity('with types');
        }, 60_000);
    }

    public override async dispose(): Promise<void> {
        clearInterval(this.timer);
    }
}

setActivity needs a logged-in client, so Presence starts its timer in ready(). dispose() clears that timer, since an interval left running keeps calling a client that has logged out.

Gateway only

this.core.bot holds the discord.js client, which only a gateway bot has. Discord takes a presence update (opens in a new tab) over the gateway alone, so an http bot can't run Presence, though init(), ready(), and dispose() work the same there.

Where each one runs

Startup runs three phases in order.

startup phasewhat has happenedplugins
Configurationnothing connectedinit() runs here by default
Loginthe gateway session opens, and http runs its REST-only startup work
Readyhandlers are live and interactions dispatchready() always runs here

Once Ready finishes, a bot on a server starts answering its health path. Nothing a plugin adds runs after it, so a host that waits on the health path sees a bot whose ready() calls have all finished.

On a server, shutdown runs four phases. A bot on Cloudflare Workers never reaches them.

shutdown phasewhat happensplugins
Unbindnew interactions and requests stop
Drainin-flight work finishes and internal services stop
Disconnectexternal resources closedispose() runs here by default
Logoutthe client disconnects last, so a draining handler can still reference it

Say you attach db, then settings, which loads each server's settings from db. Both write all three methods and keep the default phases.

startup
├─ Configuration
│  ├─ db.init()
│  └─ settings.init()     starts once db.init() has finished
├─ Login
└─ Ready
   ├─ db.ready()          every init() has finished
   └─ settings.ready()

shutdown
├─ Unbind
├─ Drain
├─ Disconnect
│  ├─ settings.dispose()  db is still open
│  └─ db.dispose()
└─ Logout

Your plugins start in the order you attach them and stop in the reverse order. Attach a plugin before any plugin that reads from it. seedcord puts all of a phase's plugin calls in one task, so they run one after another while the phase's other tasks run in parallel with them. Your handlers can already receive interactions and events while ready() is still running. Set up anything a handler needs in init(), and leave it in the default Configuration phase.

On a gateway bot, the Login phase loads your handlers and logs in, and tasks within a phase run in parallel. An init() moved to Login can still be running when a handler receives its first event.

Moving a method to another phase

The defaults fit most plugins, but some don't. If your dispose() writes a final batch into a database that another plugin connects, that write has to finish before the database plugin disconnects. Your dispose() then belongs in Drain, one phase earlier. Pass a PluginLifecycleSpec as the second argument to super(). If you leave a field out, it keeps its default.

import {
    Plugin,
    StartupPhase,
    ShutdownPhase
} from '@seedcord/gateway';

import type { CoreBase } from '@seedcord/core';

export class Presence extends Plugin {
    public constructor(host: CoreBase) {
        super(host, {
            init: { phase: StartupPhase.Login },
            ready: { timeout: 20_000 },
            dispose: { phase: ShutdownPhase.Drain, timeout: 30_000 }
        });
    }

    public async init(): Promise<void> {}
}

Presence moves init() to Login, gives ready() 20 seconds, and moves dispose() to Drain with 30 seconds. The ready field takes a timeout without a phase, since ready() always runs in the Ready phase. If you move an init() to Ready, it still runs before every plugin's ready().

Timeouts

An init() waiting on a service that never answers would hang your bot's startup forever, so each method gets its own timeout. init() and ready() default to 15000ms, and dispose() defaults to 10000ms.

If a method runs past its timeout, seedcord throws LifecycleTaskTimeout. The task name in the message carries the attach key and which method ran.

Task "Plugin (db)" timed out after 15000ms. Check what it waits on, or give it a longer timeout.
Task "Plugin:db:ready" timed out after 15000ms. Check what it waits on, or give it a longer timeout.
Task "Plugin:db:dispose" timed out after 10000ms. Check what it waits on, or give it a longer timeout.

A timeout of zero, a negative number, or Infinity throws LifecycleInvalidTimeout in the constructor, before any of your code runs.

The shutdown deadline

Those timeouts limit one method each. Your hosting platform kills the process if shutdown as a whole takes too long, so seedcord also bounds the whole shutdown with a single deadline, 25000ms by default. Once it elapses seedcord skips the phases it never reached and exits. A task already running keeps going until the process exits three seconds later. A deadline of zero, a negative number, or Infinity throws LifecycleInvalidTimeout.

If shutdown starts while the bot is still starting, it waits for startup to finish, and that wait counts against the same deadline. If the deadline passes first, seedcord doesn't dispose the plugins that finish starting after that.

The 25000ms default fits the 30 seconds Kubernetes waits (opens in a new tab) before SIGKILL, with the three-second exit delay added. docker stop (opens in a new tab) allows 10 seconds, so set shutdownDeadline to 5000 there, which leaves two seconds after the exit delay. You can also widen the window with docker stop -t.

src/bot.ts
import { Seedcord } from '@seedcord/gateway';
import { GatewayIntentBits } from 'discord.js';

const seedcord = new Seedcord({
    bot: {
        clientOptions: { intents: [GatewayIntentBits.Guilds] },
        interactions: { path: './handlers' },
        commands: { path: null },
        events: { path: null }
    },
    subscribers: { path: null },
    // 25s plus the 3s log flush fits inside kubernetes' 30s window
    lifecycle: { shutdownDeadline: 25_000 }
});

When init fails

An init() usually fails because a service it connects to is down or can't be reached. When that happens, startup stops and start() rejects with LifecyclePhaseFailures, carrying your error in its errors array. What gets cleaned up depends on whether init() threw or ran out of time.

When it throws

seedcord calls dispose() on every plugin whose init() had already finished, in reverse attach order. The plugin that threw doesn't get a dispose() call. Anything its init() opened before the error stays open unless init() closes it.

src/plugins/Feeds.ts
export class Feeds extends Plugin {
    private readonly client = new FeedClient();

    public async init(): Promise<void> {
        await this.client.connect();
        try {
            await this.client.subscribe('updates');
        } catch (error) {
            await this.client.close();
            throw error;
        }
    }
}

If subscribe throws, Feeds closes the connection in its catch, then rethrows so startup still fails.

When it times out

Startup fails, but your init() keeps running in the background. What happens next depends on whether it finishes:

  • It finishes later. seedcord calls that plugin's dispose() as soon as it does, without waiting for the other plugins' dispose() calls. If that dispose() throws, seedcord only logs a warning.
  • It never finishes. dispose() never runs. Give whatever init() waits on its own timeout, so a hang turns into an error your catch handles.

Warning

Once startup has failed, the same Seedcord can't start again. Calling start() on it a second time throws LifecycleRestartAfterFailure, so exit and let your process manager start a fresh one. Under seedcord dev, press r instead, which loads your bot into a fresh Seedcord.

When dispose fails

If one dispose() fails, the others still run. With db and settings from earlier, a throw from settings.dispose() still lets db.dispose() close the connection. Once they've all run, seedcord logs the failures under "Coordinated shutdown failed" and carries on with the next phase. Your code can't catch them. If several dispose() calls in one phase failed, the log shows one error carrying PluginDisposeFailures, with each error in its errors array. A dispose() that fails during a startup rollback is logged as a warning, and start() still rejects with the startup error.

On Cloudflare Workers

A Workers bot has no start() call, so its plugins start when the first POST reaches an isolate. init() and ready() run in the same phases and order as above, and requests that arrive meanwhile wait for them. Cloudflare can run several isolates of your bot at once. Each one starts its own copy of every plugin, so a field like a counter holds one value per isolate.

Cloudflare gives a Worker's isolate (opens in a new tab) no shutdown hook, so the shutdown phases never run. dispose() runs only when startup fails. Then seedcord disposes the plugins whose init() had finished, in reverse attach order, and the request that triggered startup gets the error. A plugin can't flush its state on the way out, so write it as you go. After a failed startup the next request starts up again, so a plugin's init() can run a second time on the same instance, after its dispose(). Write init() so it can run twice.

shutdown.addTask throws CoreLifecycleUnavailable on a Workers bot, and lifecycle isn't a config key there.