Good to see the architecture becoming concrete. In the previous lesson, you placed Frida, terminal I/O, and storage behind feature-owned ports. That isolates how the tool talks to its environment. Now we define a rule set for when an instrumentation session may perform each operation.
An instrumentation session has a real lifecycle: it is not attached, it is attaching, it is attached, it may be recording, and it is eventually detached. Encoding that lifecycle as scattered booleans such as isAttached, isRecording, and isDetaching permits contradictory combinations. An explicit state machine makes those invalid combinations unrepresentable and rejects operations that do not make sense at the current point in the lifecycle.
By the end of this lesson, you will have a transition model and a pure TypeScript reducer for session lifecycle decisions. The actual Frida adapter will remain outside this model; later handlers will use the model to decide whether it is safe to call an outbound port.
A state machine is a finite set of legal situations
A finite state machine describes:
- a finite set of states;
- events that may occur;
- transitions that define which event is valid in each state and what state results.
The key property is determinism: for a particular current state and event, the machine has one defined outcome. If no transition exists, the event is not permitted in that state.
What are state machines and statecharts?
Read Stately’s concise introduction to state machines. It establishes the vocabulary we will use for the instrumentation-session lifecycle and explains why explicitly listing transitions exposes impossible states.
In the opening section, read from the core motivation through the benefits list. Then find the subsection “Transitions and events” and read its transition explanation. Focus on the distinction between a state, an event, and a transition—not on the UI-oriented examples.
Consider a naïve session representation:
interface UnsafeSessionStatus {
readonly isAttached: boolean;
readonly isRecording: boolean;
readonly isDetaching: boolean;
}
This permits states such as:
- detached but recording;
- attaching and detaching at once;
- recording while the recorder is still starting;
- attached to two targets if a second attach command races with the first.
Each boolean looks innocent independently, but their combinations form a much larger state space than the lifecycle actually permits.
A state machine instead gives the session one discriminating state at a time. For example, recording is not a separate fact that may be combined arbitrarily with attachment. It is a distinct lifecycle state that necessarily contains an active connection and a recording identity.

The supplied order example also illustrates an important design point: an action is not universally valid merely because the application supports it. An order can be cancelled while pending or paid, but not after delivery. Likewise, start-recording is meaningful only when an instrumentation session is fully attached.
Choose states that reflect real operational commitments
For the first usable version of Grasp, model this lifecycle:
| Current state | Accepted event | Resulting state | Why it is valid |
|---|---|---|---|
detached | attach.requested | attaching | An attach attempt has begun for a selected process. |
attaching | attach.succeeded | attached | The Frida connector established a usable session. |
attaching | attach.failed | detached | No session is available, so a later attach attempt is safe. |
attached | recording.start.requested | starting-recording | A recording resource is being opened. |
starting-recording | recording.started | recording | Events may now be accepted for durable recording. |
starting-recording | recording.start.failed | attached | Attachment remains usable even though recording did not start. |
recording | recording.stop.requested | stopping-recording | The active recording is being closed. |
stopping-recording | recording.stopped | attached | Recording has ended; the Frida session remains attached. |
stopping-recording | recording.stop.failed | recording | Retain the conservative assumption that recording remains active. |
attached | detach.requested | detaching | It is safe to close an attached session with no active recorder. |
detaching | detach.completed | detached | The connection has been released. |
detaching | detach.failed | attached | The runtime reports that the session may still be usable. |
There is also one important exceptional event:
| Current state | Exceptional event | Resulting state |
|---|---|---|
| Any state holding a known live connection | transport.detached for that connection | detached |
transport.detached represents a fact reported by the technical boundary: the target terminated, Frida detached unexpectedly, or the agent connection was destroyed. It is not a command that the operator asks for. The state machine must handle it because external lifecycle changes can occur at any time.
Why include transient states?
attaching, starting-recording, stopping-recording, and detaching represent work that is in progress. They prevent race conditions at the application-policy level.
While in attaching, a second attach command is invalid. While in starting-recording, start-recording is invalid again, and recording events must not yet be persisted. While in stopping-recording, a new recording cannot begin until the current recorder has reached a known outcome.
This is preferable to setting isRecording = true before a file is opened or clearing it before the recorder has actually closed.
A deliberate lifecycle rule: stop before normal detach
This model permits a normal detach.requested only in attached, not in recording.
That rule makes operator intent clear:
- Stop the recording and wait for its outcome.
- Detach the Frida session.
An unexpected transport detach is different: it forces the session to detached, because the connection no longer exists regardless of the desired cleanup sequence. A later lesson on graceful shutdown will use this distinction to remove probes, unload the agent, close the recorder, and restore the terminal predictably.
State is not the same thing as all session data
A state machine should model lifecycle status, not become a dumping ground for every detail known about a session.
The state needs enough immutable context to enforce the lifecycle correctly:
- the target process while attachment is in progress;
- the session identity after connection;
- the recording identity while recording starts, runs, or stops.
It does not need to contain:
- raw Frida
SessionorScriptobjects; - a mutable array of every installed probe;
- terminal output strings;
- filesystem handles;
- a history of every error encountered.
Those belong behind the ports and adapters established in the prior lesson, or in other domain models.
Probe installation is a useful boundary case. Adding or removing a probe does not change the basic session lifecycle from attached to some new permanent lifecycle state. Instead, it has an admission rule:
| Session state | May add or remove probes? |
|---|---|
detached, attaching, starting-recording, stopping-recording, detaching | No |
attached, recording | Yes |
The probe registry, introduced later, will own the collection of probes. The session state machine merely answers whether a probe operation is permitted at this moment. Keeping those responsibilities separate avoids a combinatorial state explosion such as attached-with-three-probes-recording.
State pattern (TypeScript Design Patterns)
Watch “State pattern (TypeScript Design Patterns)” by Simply Explained for a compact illustration of state-specific operations. The example uses orders rather than instrumentation, but it demonstrates the essential discipline: valid actions depend on the current state.
Watch the overview for the order-lifecycle motivation, then the state contract to see how transition operations are made explicit in TypeScript. Continue with transition enforcement, paying attention to how invalid operations are blocked. For Grasp, we will keep the transition graph visibly centralized in a pure reducer rather than distribute it among mutable state classes.
The State design pattern in the video is a valid implementation technique, especially when each state owns large, genuinely different behavior. For the current session lifecycle, a central transition function is more transparent: a reviewer can inspect all permitted transitions in one place, and unit tests can enumerate them directly.
Encode the lifecycle with discriminated unions
Start with the stable value objects already introduced in the TypeScript baseline. The following assumes branded identifiers such as ProcessId, InstrumentationSessionId, and RecordingId already exist in the Local Instrumentation domain.
import type {
InstrumentationSessionId,
ProcessId,
RecordingId,
} from "../identifiers.js";
export interface ProcessTarget {
readonly processId: ProcessId;
readonly displayName: string;
}
export interface SessionConnection {
readonly sessionId: InstrumentationSessionId;
readonly target: ProcessTarget;
}
export type InstrumentationSessionState =
| {
readonly kind: "detached";
}
| {
readonly kind: "attaching";
readonly target: ProcessTarget;
}
| {
readonly kind: "attached";
readonly connection: SessionConnection;
}
| {
readonly kind: "starting-recording";
readonly connection: SessionConnection;
readonly recordingId: RecordingId;
}
| {
readonly kind: "recording";
readonly connection: SessionConnection;
readonly recordingId: RecordingId;
}
| {
readonly kind: "stopping-recording";
readonly connection: SessionConnection;
readonly recordingId: RecordingId;
}
| {
readonly kind: "detaching";
readonly connection: SessionConnection;
};
The kind property is the discriminant. Once TypeScript narrows, for example, state.kind === "recording", it knows that connection and recordingId are both available. Conversely, code handling detached cannot accidentally reach for a session ID that does not exist.
Events are also a discriminated union:
export type InstrumentationSessionEvent =
| {
readonly type: "attach.requested";
readonly target: ProcessTarget;
}
| {
readonly type: "attach.succeeded";
readonly connection: SessionConnection;
}
| {
readonly type: "attach.failed";
}
| {
readonly type: "recording.start.requested";
readonly recordingId: RecordingId;
}
| {
readonly type: "recording.started";
}
| {
readonly type: "recording.start.failed";
}
| {
readonly type: "recording.stop.requested";
}
| {
readonly type: "recording.stopped";
}
| {
readonly type: "recording.stop.failed";
}
| {
readonly type: "detach.requested";
}
| {
readonly type: "detach.completed";
}
| {
readonly type: "detach.failed";
}
| {
readonly type: "transport.detached";
readonly sessionId: InstrumentationSessionId;
};
Notice the separation of responsibilities:
attach.requestedexpresses an accepted intent to begin attachment.attach.succeededandattach.failedexpress the outcome of calling the session connector port.transport.detachedexpresses an external lifecycle fact.- The event union contains no Frida-specific class, callback, or raw error object.
An application handler may retain a structured error to return to the terminal after attach.failed, but that error does not need to become a long-lived state property. The lifecycle only needs to know that the system is again detached and may accept a fresh attempt.
Make the transition decision a pure function
The transition function is the machine’s gatekeeper. Given an immutable current state and one event, it either produces the next state or returns a typed refusal.
import type { Result } from "../../shared/result.js";
import type {
InstrumentationSessionEvent,
InstrumentationSessionState,
} from "./instrumentation-session-state.js";
export type InvalidSessionTransition = {
readonly kind: "InvalidSessionTransition";
readonly state: InstrumentationSessionState["kind"];
readonly event: InstrumentationSessionEvent["type"];
readonly message: string;
};
export function transition(
state: InstrumentationSessionState,
event: InstrumentationSessionEvent,
): Result<InstrumentationSessionState, InvalidSessionTransition> {
if (event.type === "transport.detached") {
if (hasConnectionWithId(state, event.sessionId)) {
return accepted({ kind: "detached" });
}
// A late notification for an older session has no effect.
return accepted(state);
}
switch (state.kind) {
case "detached":
if (event.type === "attach.requested") {
return accepted({
kind: "attaching",
target: event.target,
});
}
return rejected(state, event);
case "attaching":
if (event.type === "attach.succeeded") {
return accepted({
kind: "attached",
connection: event.connection,
});
}
if (event.type === "attach.failed") {
return accepted({ kind: "detached" });
}
return rejected(state, event);
case "attached":
if (event.type === "recording.start.requested") {
return accepted({
kind: "starting-recording",
connection: state.connection,
recordingId: event.recordingId,
});
}
if (event.type === "detach.requested") {
return accepted({
kind: "detaching",
connection: state.connection,
});
}
return rejected(state, event);
case "starting-recording":
if (event.type === "recording.started") {
return accepted({
kind: "recording",
connection: state.connection,
recordingId: state.recordingId,
});
}
if (event.type === "recording.start.failed") {
return accepted({
kind: "attached",
connection: state.connection,
});
}
return rejected(state, event);
case "recording":
if (event.type === "recording.stop.requested") {
return accepted({
kind: "stopping-recording",
connection: state.connection,
recordingId: state.recordingId,
});
}
return rejected(state, event);
case "stopping-recording":
if (event.type === "recording.stopped") {
return accepted({
kind: "attached",
connection: state.connection,
});
}
if (event.type === "recording.stop.failed") {
return accepted({
kind: "recording",
connection: state.connection,
recordingId: state.recordingId,
});
}
return rejected(state, event);
case "detaching":
if (event.type === "detach.completed") {
return accepted({ kind: "detached" });
}
if (event.type === "detach.failed") {
return accepted({
kind: "attached",
connection: state.connection,
});
}
return rejected(state, event);
}
}
function accepted(
state: InstrumentationSessionState,
): Result<InstrumentationSessionState, InvalidSessionTransition> {
return {
ok: true,
value: state,
};
}
function rejected(
state: InstrumentationSessionState,
event: InstrumentationSessionEvent,
): Result<InstrumentationSessionState, InvalidSessionTransition> {
return {
ok: false,
error: {
kind: "InvalidSessionTransition",
state: state.kind,
event: event.type,
message: `Cannot handle ${event.type} while session is ${state.kind}.`,
},
};
}
function hasConnectionWithId(
state: InstrumentationSessionState,
sessionId: InstrumentationSessionEvent extends {
readonly type: "transport.detached";
readonly sessionId: infer Id;
}
? Id
: never,
): boolean {
switch (state.kind) {
case "attached":
case "starting-recording":
case "recording":
case "stopping-recording":
case "detaching":
return state.connection.sessionId === sessionId;
case "detached":
case "attaching":
return false;
}
}
In production code, prefer importing InstrumentationSessionId directly in hasConnectionWithId rather than deriving it from the event union. The slightly compact type above merely shows that a transport-detach signal must carry a session identity.
Several properties make this function valuable:
-
It is pure. It calls neither Frida nor
node:fs, mutates no object, prints nothing, and depends only on its arguments. -
It is explicit. A reviewer can answer “can recording start while detaching?” by looking at the
detachingbranch. The answer is no. -
It preserves data intentionally. When recording starts, the connection persists. When stopping fails, the recording ID remains. Each state says exactly what still exists.
-
It returns an expected failure. A rejected command is not an exception. The CLI or REPL can render a clear message such as: “Cannot handle
recording.start.requestedwhile session isdetached.” -
It handles stale lifecycle notifications safely. An asynchronous detach notification for an old session must not detach a newer one. Matching the event’s session ID protects the current lifecycle state.
The conditional type in hasConnectionWithId is more clever than necessary for everyday code. In your project, keep the helper clearer:
import type { InstrumentationSessionId } from "../identifiers.js";
function hasConnectionWithId(
state: InstrumentationSessionState,
sessionId: InstrumentationSessionId,
): boolean {
// Same switch body as above.
return false;
}
The important design is the session-ID comparison, not the type-level trick.
Keep I/O outside the state transition
The state machine should decide whether an operation may begin. An application handler then coordinates the port call and feeds its outcome back into the machine.
For an attach operation, the handler’s sequence is:
- Read the current session state.
- Submit
attach.requestedto the pure transition function. - If rejected, return
InvalidSessionTransitionwithout calling Frida. - Store the resulting
attachingstate. - Call the
SessionConnectorport. - Submit either
attach.succeededorattach.failed. - Store the resulting stable state and return a typed application result.
This sequencing matters. Entering attaching before the Frida call prevents a second command from beginning a competing attach attempt.
The same structure applies to recording:
- Generate a
RecordingId. - Transition from
attachedtostarting-recording. - Ask the recorder adapter to open its destination.
- Transition to
recordingonly after the recorder reports success.
The machine does not open files. The recorder adapter does not decide whether the session is allowed to record. Each layer keeps its responsibility.
State-machine inputs are not yet domain events
The union named InstrumentationSessionEvent represents inputs to a transition decision. Some are commands or internal operation outcomes, such as detach.requested and detach.completed.
In the next lesson, you will introduce immutable domain events that record significant facts for other parts of the application, such as:
InstrumentationSessionAttachedRecordingStartedRecordingStoppedInstrumentationSessionDetached
Those facts may be displayed, recorded, or handled by later features. Do not conflate them with transition inputs. A state-machine event determines what is permitted now; a domain event communicates something that has happened.
Test the graph rather than only command handlers
Because transition() is pure, tests need no running Windows process, agent bundle, REPL, or filesystem. They can test the lifecycle graph directly.
At minimum, cover:
- every valid transition in the table;
- representative invalid commands from each state;
- failure recovery, such as
recording.stop.failedreturning torecording; - a matching
transport.detachedsignal forcingdetached; - a stale detach signal leaving a newer session unchanged.
A focused Vitest test can assert a lifecycle rule clearly:
import { describe, expect, it } from "vitest";
describe("instrumentation session transitions", function () {
it("requires recording to stop before normal detach", function () {
const state: InstrumentationSessionState = {
kind: "recording",
connection: fixtureConnection,
recordingId: fixtureRecordingId,
};
const result = transition(state, {
type: "detach.requested",
});
expect(result).toMatchObject({
ok: false,
error: {
kind: "InvalidSessionTransition",
state: "recording",
event: "detach.requested",
},
});
});
});
A compact table-driven suite is appropriate once the fixture values exist. It should enumerate the transition matrix rather than reimplement the transition rules inside the test. The production table and the test cases should be easy to compare visually during a code review.
As an implementation checkpoint, add a domain file such as:
src/domain/local-instrumentation/instrumentation-session-state.ts
Keep it independent of Frida, Node.js, terminal adapters, and file adapters. Then make session-oriented application handlers consult it before using the SessionConnector or RecordingAppender ports.
Key takeaways
An explicit state machine makes the instrumentation lifecycle safe and inspectable:
- A session occupies exactly one lifecycle state at a time.
- In-progress states such as
attachingandstopping-recordingprevent overlapping operations. - Events define the only valid ways to change state.
- A pure transition function either returns the next immutable state or a typed invalid-transition error.
- Frida calls, file operations, and CLI rendering remain outside the machine, in application handlers and adapters.
- External detach notifications must be correlated to the active session identity so stale callbacks cannot damage a newer session.
- Probe management is admitted by session state but belongs to its own registry model, rather than multiplying lifecycle states.
Next, you will represent important transitions and session actions as immutable domain events, without coupling those facts to terminal output or persistent recording.
Can't find a good explanation? Sign up and we'll make it for you
Sign up