Create your own
Lesson illustration

Idempotent Session Lifecycle Management

Good to see the host–agent boundary taking shape. So far, the host can attach to the authorized fixture, wait for a validated agent.ready message, and invoke a typed RPC operation. Those capabilities are useful only while the Frida session and injected script remain alive.

This lesson makes that lifetime explicit. A target may exit at any time, the agent script may be unloaded or destroyed independently, and your own shutdown path may trigger both notifications as a consequence of cleanup. By the end, you will have a small per-session lifecycle owner that treats all of those cases as normal terminal events, performs cleanup at most once, and gives callers a reliable terminal status.

This is deliberately an infrastructure lifecycle, focused on a live Frida attachment and script. In Module 2 you will model the richer domain state machine for instrumentation sessions, probes, and recording. Keeping those concerns distinct now prevents Frida callback timing from leaking into the domain model.


A session and an agent script can end in different ways

There are two host-side notifications to handle:

  • session.detached means the Frida attachment is no longer usable. A target process exiting is one reason this happens.
  • script.destroyed means the injected agent script is no longer usable. The target might still be running and the Frida session might still be attached.

The distinction matters. If the script is destroyed while the session remains attached, RPC exports, message handling, and hooks belonging to that script are gone. You should not keep treating script.exports as a usable proxy. Conversely, once a session detaches, attempting further script operations is usually pointless: the target-side connection has already ended.

Frida’s official JavaScript API documentation introduces the host-side session detach notification alongside the message and RPC facilities used in the previous lessons.

JavaScript API | Frida • A world-class dynamic instrumentation toolkit

Read the Frida documentation’s communication section to connect the familiar message and RPC APIs with the host-side notification that reports target-session termination.

In the “Communication between host and injected process” section, briefly review the distinction between send(), recv(), and rpc.exports. Then continue into the paragraph immediately after the Node.js and Python examples. Read the notification guidance, focusing on the fact that a host can observe session termination instead of discovering it only through failed RPC calls.

The following table identifies the situations your lifecycle must normalize:

SituationInitial notification or actionWhat is no longer safeLifecycle response
User requests detachYour CLI or shutdown handler calls close()Soon, both the script and sessionUnload the script, then detach the session.
Target exitssession.detachedThe session and scriptStop ingress and terminalize locally; do not depend on remote cleanup succeeding.
Agent is unloaded deliberatelyscript.destroyedThat script’s RPC exports, hooks, and messagesStop using the script and detach the still-live session.
Agent fails or is destroyed unexpectedlyscript.destroyed, perhaps followed by session.detachedThe script, and potentially soon the sessionBegin terminal cleanup once; tolerate a later detach notification.
Cleanup itself emits notificationsscript.unload() and session.detach()Depends on the operationTreat callbacks as duplicate observations, not new cleanup work.

The final row is where many first implementations fail. Calling script.unload() may lead to script.destroyed; detaching the session may lead to session.detached. If each callback starts a new teardown routine, cleanup becomes re-entrant: listeners are removed twice, detach is requested repeatedly, and rejected promises may escape into the REPL.

The central rule is therefore:

Every terminal path requests the same cleanup operation. The first caller starts it; every later caller receives the same completion promise.

That is idempotence in the form that matters for asynchronous resource lifecycles.


Give terminal state a typed, observable shape

Avoid reducing session state to a boolean such as isAttached. That boolean cannot answer essential questions:

  • Is startup still awaiting agent.ready?
  • Has shutdown begun but not completed?
  • Did the target detach, or did the user request cleanup?
  • Did cleanup finish with diagnostic warnings?

Use a discriminated union instead. Reuse your branded SessionId type from Module 1 if it already exists.

export type SessionEndCause =
  | {
      readonly kind: "requested";
    }
  | {
      readonly kind: "target-detached";
      readonly reason: string;
    }
  | {
      readonly kind: "script-destroyed";
    }
  | {
      readonly kind: "startup-failed";
      readonly detail: string;
    };

export type CleanupWarning = Readonly<{
  readonly operation: "unload-script" | "detach-session";
  readonly detail: string;
}>;

export type FridaLifecycleState =
  | {
      readonly kind: "loading";
    }
  | {
      readonly kind: "ready";
    }
  | {
      readonly kind: "closing";
      readonly cause: SessionEndCause;
    }
  | {
      readonly kind: "closed";
      readonly cause: SessionEndCause;
      readonly warnings: readonly CleanupWarning[];
    }>;

There are two useful design decisions here.

First, closing is separate from closed. A call to close() begins immediately, but script.unload() and session.detach() are asynchronous. Reporting the session as closed before those operations settle would let later code make false assumptions.

Second, cleanup failures become data. For example, if the target has just exited, script.unload() may reject because the remote script no longer exists. That does not mean local lifecycle cleanup failed. The lifecycle should still reach closed, while retaining a warning for diagnostics or session history.

A SessionEndCause records the first event that initiated shutdown. It should not be overwritten by later, expected consequences:

  • A user-requested close remains requested, even if the subsequent detach event arrives.
  • A target-exit detach remains target-detached, even if Frida also reports that the script was destroyed.
  • A script-destroyed event remains script-destroyed, even if the lifecycle then detaches the session itself.

Preserving that causal story will make later CLI output and recording summaries much more useful.


Contain Frida signals in one per-session owner

Do not build a global mutable “current session” object. The lifecycle object should own exactly one Session and one Script. It is constructed after session.createScript() succeeds and before script.load() begins, so it cannot miss a destruction event during startup.

The small structural interfaces below make the owner testable without requiring a real Frida process. In production, frida.Session and frida.Script satisfy this shape through the Frida TypeScript definitions.

interface Listener<Arguments extends readonly unknown[]> {
  (...arguments_: Arguments): void;
}

interface Signal<Arguments extends readonly unknown[]> {
  connect(listener: Listener<Arguments>): void;
  disconnect(listener: Listener<Arguments>): void;
}

interface ScriptLike {
  readonly destroyed: Signal<[]>;
  unload(): Promise<void>;
}

interface SessionLike {
  readonly detached: Signal<[reason: unknown, crash: unknown | null]>;
  detach(): Promise<void>;
}

Keep message-listener ownership explicit. The lifecycle does not need to parse or render messages, but it needs a callback that can stop their ingress during shutdown. In your current smoke runner, that callback can disconnect the script.message handler you registered before loading the script.

Here is a lifecycle owner suitable for the current host layer:

export class AttachedAgentLifecycle {
  readonly #session: SessionLike;
  readonly #script: ScriptLike;
  readonly #stopMessageIngress: () => void;

  #state: FridaLifecycleState = {
    kind: "loading",
  };

  #closePromise: Promise<FridaLifecycleState> | undefined;
  #targetDetachedObserved = false;

  #onScriptDestroyed = (): void => {
    void this.close({
      kind: "script-destroyed",
    });
  };

  #onSessionDetached = (
    reason: unknown,
    _crash: unknown | null,
  ): void => {
    this.#targetDetachedObserved = true;

    void this.close({
      kind: "target-detached",
      reason: describeUnknown(reason),
    });
  };

  constructor(
    session: SessionLike,
    script: ScriptLike,
    stopMessageIngress: () => void,
  ) {
    this.#session = session;
    this.#script = script;
    this.#stopMessageIngress = stopMessageIngress;
  }

  get state(): FridaLifecycleState {
    return this.#state;
  }

  startObserving(): void {
    this.#script.destroyed.connect(this.#onScriptDestroyed);
    this.#session.detached.connect(this.#onSessionDetached);
  }

  markReady(): void {
    if (this.#state.kind === "loading") {
      this.#state = {
        kind: "ready",
      };
    }
  }

  close(cause: SessionEndCause): Promise<FridaLifecycleState> {
    if (this.#closePromise !== undefined) {
      return this.#closePromise;
    }

    this.#state = {
      kind: "closing",
      cause,
    };

    let resolveCompletion!: (state: FridaLifecycleState) => void;

    const completion = new Promise<FridaLifecycleState>(
      function createCompletion(resolve) {
        resolveCompletion = resolve;
      },
    );

    /*
     * Store the promise before starting asynchronous cleanup. If unloading
     * synchronously causes a destroyed notification, that callback sees this
     * same promise instead of beginning cleanup again.
     */
    this.#closePromise = completion;

    void this.finishClose(cause).then(resolveCompletion);

    return completion;
  }

  private async finishClose(
    cause: SessionEndCause,
  ): Promise<FridaLifecycleState> {
    const warnings: CleanupWarning[] = [];

    try {
      this.#stopMessageIngress();
    } catch (error) {
      warnings.push({
        operation: "unload-script",
        detail: `Could not stop message ingress: ${describeUnknown(error)}`,
      });
    }

    /*
     * If Frida already told us the target detached, remote cleanup cannot be
     * relied upon. If the script is already destroyed, unloading it again is
     * unnecessary.
     */
    if (
      cause.kind !== "target-detached" &&
      cause.kind !== "script-destroyed"
    ) {
      await this.attempt(
        "unload-script",
        () => this.#script.unload(),
        warnings,
      );
    }

    /*
     * A script-destroyed event does not necessarily end the Frida session.
     * Release that still-live host resource. If a detach was observed while
     * cleanup was in progress, skip the redundant remote call.
     */
    if (
      cause.kind !== "target-detached" &&
      !this.#targetDetachedObserved
    ) {
      await this.attempt(
        "detach-session",
        () => this.#session.detach(),
        warnings,
      );
    }

    this.#script.destroyed.disconnect(this.#onScriptDestroyed);
    this.#session.detached.disconnect(this.#onSessionDetached);

    const closed: FridaLifecycleState = {
      kind: "closed",
      cause,
      warnings,
    };

    this.#state = closed;

    return closed;
  }

  private async attempt(
    operation: CleanupWarning["operation"],
    action: () => Promise<void>,
    warnings: CleanupWarning[],
  ): Promise<void> {
    try {
      await action();
    } catch (error) {
      warnings.push({
        operation,
        detail: describeUnknown(error),
      });
    }
  }
}

function describeUnknown(value: unknown): string {
  if (value instanceof Error) {
    return value.message;
  }

  return String(value);
}

A few details deserve close attention.

The first call wins

#closePromise is assigned before finishClose() begins. This ordering is important. Suppose a user requests shutdown, script.unload() causes Frida to emit script.destroyed, and the destruction callback invokes close() while cleanup is underway. The second call returns the already-created promise.

That gives every caller one coherent answer: the same eventual terminal state.

Cleanup is conditional, not blindly repeated

The lifecycle selects cleanup based on the initiating cause:

Initial causeUnload script?Detach session?
requestedYesYes, unless detach was already observed
startup-failedYes where possibleYes, unless detach was already observed
script-destroyedNoYes, unless detach was already observed
target-detachedNoNo

This is more precise than wrapping every cleanup call in a broad try block. A rejected remote operation may be expected after a target exit, but it should not be the normal control flow for every terminal state.

The listener callback must not throw

Notice the use of:

void this.close(...)

A Frida signal callback should initiate cleanup and return promptly. The lifecycle captures errors from cleanup as warnings and resolves its completion promise with a terminal state. Avoid leaving an unhandled rejected promise inside a signal callback; such failures are difficult to correlate with the session that caused them.


Wire it before script loading, and make startup cancellable

Create and start the lifecycle observer as soon as both host objects exist:

const session = await frida.attach(fixturePid);
const script = await session.createScript(agentSource);

const onMessage = createAgentMessageHandler(/* existing dependencies */);

script.message.connect(onMessage);

const lifecycle = new AttachedAgentLifecycle(
  session,
  script,
  function stopMessageIngress() {
    script.message.disconnect(onMessage);
  },
);

lifecycle.startObserving();

try {
  await script.load();

  await waitForValidatedAgentReady({
    /* existing ready-message dependencies */
  });

  lifecycle.markReady();
} catch (error) {
  await lifecycle.close({
    kind: "startup-failed",
    detail: describeUnknown(error),
  });

  throw error;
}

The order is intentional:

  1. Register the message listener so that an immediate agent.ready is not missed.
  2. Register the destroyed and detached listeners before loading the agent.
  3. Load the script.
  4. Wait for the already-established readiness protocol.
  5. Mark the lifecycle ready only after that protocol has validated.

Your existing waitForValidatedAgentReady() should also be able to stop waiting when the lifecycle ends. Otherwise, a target that exits during startup may leave the host awaiting a message that can never arrive.

One straightforward approach is for the lifecycle to own an AbortController and expose its signal. Abort it when close() starts; then pass that signal into readiness waiting and any longer-running host operation. The waiting function should disconnect its temporary message listener and reject with a typed “session ended during startup” error when the signal aborts.

The same principle applies to RPC. Before initiating an RPC call, require lifecycle.state.kind === "ready". Once the script is destroyed or the session is detached, do not issue new calls through script.exports. An RPC already in flight may still reject after the lifecycle begins closing; translate that rejection to the application-level failure model introduced earlier, rather than attempting a retry against a destroyed agent.


Verify idempotence with deterministic fakes

This lifecycle logic is ideal for Vitest unit tests because no real target process is necessary. Build tiny fake Signal, ScriptLike, and SessionLike implementations that record:

  • how many times unload() was called,
  • how many times detach() was called,
  • which listeners remain connected,
  • whether actions resolve or reject.

The core assertions should be behavioral:

const first = lifecycle.close({
  kind: "requested",
});

const second = lifecycle.close({
  kind: "requested",
});

expect(second).toBe(first);

await first;

expect(fakeScript.unloadCalls).toBe(1);
expect(fakeSession.detachCalls).toBe(1);
expect(lifecycle.state.kind).toBe("closed");

Test the notification paths separately:

Test caseTriggerExpected result
Target detaches while readyEmit session.detachedState becomes closed with target-detached; no script unload is attempted.
Script is destroyed while readyEmit script.destroyedState becomes closed with script-destroyed; session detach is attempted once.
Explicit close triggers callbacksMake fake unload() emit destruction and fake detach() emit detachCleanup operations each run once, not recursively.
Detach races explicit closeStart requested close, then emit detach before cleanup completesOne promise and one terminal state; the detach is observed without starting a second cleanup.
Remote cleanup rejectsReject fake unload or detachLifecycle still ends in closed, with a CleanupWarning.

For the real authorized fixture integration check, run two scenarios:

  1. Attach, load, receive agent.ready, invoke RPC, then call lifecycle.close({ kind: "requested" }).
  2. Attach and load the fixture, then allow the fixture process to terminate while the host is connected.

The second scenario is not about requiring a particular ordering of destroyed and detached; that ordering can vary with timing. The valuable assertion is that the host reaches one terminal state without hanging, without unhandled promise rejections, and without treating the now-invalid agent RPC proxy as usable.


Keep lifecycle ownership local

At this point, a small AttachedAgentLifecycle inside the Frida infrastructure slice is appropriate. It has a narrow job:

  • observe Frida’s session and script lifetime signals,
  • stop host-side event ingress,
  • release live remote resources when possible,
  • expose one immutable terminal result.

It should not decide what a CLI command prints, write a recording file, maintain a global registry, or select a new target. Those decisions belong to application handlers and later feature slices.

The composition root will eventually construct one lifecycle instance for each successful attachment and provide a higher-level session status to the shared CLI/REPL command registry. For now, the critical boundary is simpler: no code outside this owner should independently call script.unload() or session.detach() for the same managed attachment.


Key takeaways

A Frida session can end because the user requests it, the target exits, or the injected script disappears. These are related but distinct conditions:

  • Observe both session.detached and script.destroyed.
  • Register lifetime listeners before loading the script, so startup cannot miss a terminal event.
  • Model loading, ready, closing, and closed as a discriminated union rather than a boolean.
  • Route every terminal signal through one idempotent close() method.
  • Store the cleanup promise before beginning cleanup, so callbacks caused by cleanup cannot re-enter it.
  • Treat remote cleanup failures after a detach as diagnostics, not reasons to leave local state half-closed.
  • Stop message ingress and prevent new RPC calls as soon as shutdown begins.

Next, you will expose attach, detach, and session-status use cases through the shared CLI and REPL command registry, so this reliable lifecycle becomes visible and controllable through the tool’s public interface.

Can't find a good explanation? Sign up and we'll make it for you

Sign up