@gravitylabsllc/porthole 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -0
- package/dist/adb.js +430 -21
- package/dist/adb.js.map +1 -1
- package/dist/args.js +144 -0
- package/dist/args.js.map +1 -0
- package/dist/capture.js +139 -30
- package/dist/capture.js.map +1 -1
- package/dist/cli.js +221 -62
- package/dist/cli.js.map +1 -1
- package/dist/device.js +337 -4
- package/dist/device.js.map +1 -1
- package/dist/index.js +2030 -377
- package/dist/index.js.map +1 -1
- package/dist/moment.js +240 -0
- package/dist/moment.js.map +1 -0
- package/dist/perfetto.js +826 -0
- package/dist/perfetto.js.map +1 -0
- package/dist/report.js +68 -7
- package/dist/report.js.map +1 -1
- package/dist/save.js +252 -0
- package/dist/save.js.map +1 -0
- package/dist/sessions.js +704 -0
- package/dist/sessions.js.map +1 -0
- package/dist/system.js +169 -0
- package/dist/system.js.map +1 -0
- package/dist/systrace.js +198 -0
- package/dist/systrace.js.map +1 -0
- package/dist/timeline.js +731 -29
- package/dist/timeline.js.map +1 -1
- package/dist/trace.js +317 -27
- package/dist/trace.js.map +1 -1
- package/dist/watermark.js +220 -0
- package/dist/watermark.js.map +1 -0
- package/package.json +10 -4
- package/src/adb.ts +583 -0
- package/src/args.ts +177 -0
- package/src/capture.ts +292 -0
- package/src/cli.ts +367 -0
- package/src/device.ts +635 -0
- package/src/index.ts +2545 -0
- package/src/moment.ts +306 -0
- package/src/perfetto.ts +972 -0
- package/src/report.ts +285 -0
- package/src/save.ts +322 -0
- package/src/sessions.ts +894 -0
- package/src/system.ts +221 -0
- package/src/systrace.ts +258 -0
- package/src/timeline.ts +1036 -0
- package/src/trace.ts +769 -0
- package/src/watermark.ts +337 -0
- package/ui/dist/assets/index-DtnyBXCM.css +1 -0
- package/ui/dist/assets/index-h7VNB9Fl.js +70 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index--1mlZuNZ.css +0 -1
- package/ui/dist/assets/index-BeVGHRFm.js +0 -68
package/src/device.ts
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
// Copyright 2026 Gravity Labs
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import { EventEmitter } from "node:events";
|
|
5
|
+
import { SessionWriter, type SessionEvent } from "./sessions.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One frame per line, UTF-8. A frame carrying `id` is a response to a request
|
|
9
|
+
* we sent; a frame carrying `event` is the device telling us something happened.
|
|
10
|
+
*/
|
|
11
|
+
export interface DeviceEvent {
|
|
12
|
+
event: string;
|
|
13
|
+
/** Device uptime in ms. Not wall clock — see hello.startedAt for the origin. */
|
|
14
|
+
t: number;
|
|
15
|
+
seq: number;
|
|
16
|
+
data: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Response {
|
|
20
|
+
id: number;
|
|
21
|
+
ok: boolean;
|
|
22
|
+
result?: unknown;
|
|
23
|
+
error?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Hello {
|
|
27
|
+
protocol: number;
|
|
28
|
+
packageName: string;
|
|
29
|
+
processName: string;
|
|
30
|
+
versionName: string | null;
|
|
31
|
+
device: string;
|
|
32
|
+
sdkInt: number;
|
|
33
|
+
startedAt: number;
|
|
34
|
+
collectors: string[];
|
|
35
|
+
/**
|
|
36
|
+
* GRA-53: the third leg of on-disk session identity, `(packageName,
|
|
37
|
+
* startedAt, deviceId)` — see `sessions.ts`'s module doc comment.
|
|
38
|
+
* Deliberately optional and deliberately not named `serial`: it is not
|
|
39
|
+
* adb's own device serial (that is known host-side, in the Gradle
|
|
40
|
+
* plugin's connection file — a different mechanism entirely, GRA-119's
|
|
41
|
+
* territory) and a build that has not sent one yet must degrade session
|
|
42
|
+
* identity, not break it. `sessions.ts`'s `UNKNOWN_DEVICE_ID` is the
|
|
43
|
+
* fallback every existing `hello` fixture in this test suite exercises,
|
|
44
|
+
* since none of them set this field.
|
|
45
|
+
*/
|
|
46
|
+
deviceId?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* GRA-96: the constant this side of the socket owns, matching
|
|
51
|
+
* `PROTOCOL_VERSION` in `runtime/.../protocol/Protocol.kt` — the two are not
|
|
52
|
+
* derived from a shared source, so a wire-format change obliges updating
|
|
53
|
+
* both by hand (see that file's own comment on the constant it owns). This
|
|
54
|
+
* is a bare integer with no `major.minor` split: a match means compatible,
|
|
55
|
+
* anything else is a refusal, recorded in `protocolMismatch` below rather
|
|
56
|
+
* than thrown, so the rest of the handshake can finish and the mismatch can
|
|
57
|
+
* be reported as the specific, actionable message `porthole_status` needs
|
|
58
|
+
* (GRA-96 AC1/AC2) instead of a generic connection failure.
|
|
59
|
+
*/
|
|
60
|
+
export const PROTOCOL_VERSION = 1;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* GRA-157: the socket connecting and the app saying hello are two different
|
|
64
|
+
* events, roughly 2s apart on real hardware, and treating them as one was the
|
|
65
|
+
* bug. "handshaking" names the gap: the socket is up, `request()` can already
|
|
66
|
+
* be used (hello itself goes over it), but `hello` is still null. "connected"
|
|
67
|
+
* is now a promise, not just a name — see `setState()` below, which refuses
|
|
68
|
+
* to enter it while `hello` is null, and `connect()`'s hello handler, which is
|
|
69
|
+
* the only place that promise is fulfilled. Every caller that used to write
|
|
70
|
+
* `state === "connected" && hello` can drop the `&& hello`; every caller that
|
|
71
|
+
* used to write `state === "connected"` alone and silently mean "and hello
|
|
72
|
+
* happens to be set" was the bug, and now has three states to actually name
|
|
73
|
+
* what it meant.
|
|
74
|
+
*/
|
|
75
|
+
export type ConnectionState = "disconnected" | "connecting" | "handshaking" | "connected";
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* GRA-163: the ring survives a close (timeline.ts never clears it there —
|
|
79
|
+
* only a new `hello` does, because the post-mortem case, "what happened
|
|
80
|
+
* before it died", is exactly when someone needs those events most), but
|
|
81
|
+
* that leaves a gap the ring itself cannot answer: whose process produced
|
|
82
|
+
* what is still buffered, and when did it stop. This is that answer, kept
|
|
83
|
+
* on `DeviceClient` because it is the thing that watches the socket close —
|
|
84
|
+
* set in the close handler below, read by every tool that might describe
|
|
85
|
+
* buffered data without a live session behind it.
|
|
86
|
+
*
|
|
87
|
+
* This is the counterpart to what a `hello` already does on the other side
|
|
88
|
+
* of the session boundary: a `hello` discards the previous session's ring;
|
|
89
|
+
* a close records the one that just ended, for whoever reads what is left
|
|
90
|
+
* of it afterward.
|
|
91
|
+
*
|
|
92
|
+
* **Founder's decision, 2026-09-15: label answers as belonging to the
|
|
93
|
+
* exited process, rather than clearing the ring or hiding them.**
|
|
94
|
+
* This was built as an explicitly stated assumption and carried that label
|
|
95
|
+
* until the founder confirmed it; the wording is updated here so nobody
|
|
96
|
+
* reads a settled decision as an open bet. Retaining `lastExited` (instead
|
|
97
|
+
* of, say, dropping it the moment the socket closes, which would make a
|
|
98
|
+
* stale ring silently indistinguishable from an empty one again) is what
|
|
99
|
+
* that decision means in code, recorded here because this is where someone
|
|
100
|
+
* revisiting it would start looking — a search of `index.ts` alone would not
|
|
101
|
+
* show that a decision was made, only its consequences. Were it ever
|
|
102
|
+
* reversed, the change is to stop setting this field (or to clear it, and
|
|
103
|
+
* the ring, on close) rather than to hunt through every caller. Five tests
|
|
104
|
+
* pin it and would have to change with it: `index.test.ts`'s "AC1: with a non-empty ring and the device
|
|
105
|
+
* disconnected, all three tools agree and none reports the dead process as
|
|
106
|
+
* live", "AC2/AC5: with a non-empty ring and the device handshaking again,
|
|
107
|
+
* no tool reports connected: true about the previous session's data", "AC3:
|
|
108
|
+
* on socket close the ring is kept, not cleared, and device.lastExited
|
|
109
|
+
* records who it belonged to", and "the ordinary case is unaffected:
|
|
110
|
+
* connected: true still means the ring is confirmed live, not a previous
|
|
111
|
+
* session's leftovers"; and `timeline.test.ts`'s "the ring is not cleared
|
|
112
|
+
* by a close — only by a new hello — so it still holds what an exited
|
|
113
|
+
* process produced".
|
|
114
|
+
*/
|
|
115
|
+
export interface ExitedSession {
|
|
116
|
+
/** The process that produced whatever the ring may still hold. */
|
|
117
|
+
hello: Hello;
|
|
118
|
+
/** Wall-clock time (`Date.now()`) the socket actually closed. */
|
|
119
|
+
disconnectedAt: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The one sentence every tool uses for "the socket is up, hello has not landed yet" — GRA-157 AC3. */
|
|
123
|
+
export const HANDSHAKE_PENDING_MESSAGE =
|
|
124
|
+
"Connected, waiting on the app's first check-in. Ask again in a moment.";
|
|
125
|
+
|
|
126
|
+
const RECONNECT_MIN_MS = 500;
|
|
127
|
+
const RECONNECT_MAX_MS = 5_000;
|
|
128
|
+
const REQUEST_TIMEOUT_MS = 5_000;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Talks to the app over the adb-forwarded loopback port.
|
|
132
|
+
*
|
|
133
|
+
* Reconnects on its own, because the far end is an app being actively developed:
|
|
134
|
+
* it gets killed, reinstalled and relaunched constantly, and none of that should
|
|
135
|
+
* require restarting the MCP server.
|
|
136
|
+
*/
|
|
137
|
+
export class DeviceClient extends EventEmitter {
|
|
138
|
+
private socket: net.Socket | null = null;
|
|
139
|
+
private buffer = "";
|
|
140
|
+
private nextId = 1;
|
|
141
|
+
private pending = new Map<
|
|
142
|
+
number,
|
|
143
|
+
{ resolve: (value: unknown) => void; reject: (reason: Error) => void; timer: NodeJS.Timeout }
|
|
144
|
+
>();
|
|
145
|
+
private reconnectDelay = RECONNECT_MIN_MS;
|
|
146
|
+
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
147
|
+
private closed = false;
|
|
148
|
+
|
|
149
|
+
state: ConnectionState = "disconnected";
|
|
150
|
+
hello: Hello | null = null;
|
|
151
|
+
lastError: string | null = null;
|
|
152
|
+
/**
|
|
153
|
+
* GRA-163: null until a session that actually got a `hello` has closed;
|
|
154
|
+
* from then on, the most recent one — overwritten on every subsequent
|
|
155
|
+
* close that had a `hello`, so it always names the last confirmed process,
|
|
156
|
+
* never a stale one from further back. See the close handler in
|
|
157
|
+
* `connect()` for where it is set, and index.ts's `exitedProcessField()`/
|
|
158
|
+
* `exitedProcessNotice()` for where it turns into what every tool
|
|
159
|
+
* actually reports (moved there after QA round 1: this field says whose
|
|
160
|
+
* process it is, but only the caller knows whether the ring it is about
|
|
161
|
+
* to describe is empty — see `pendingMessage()`'s own comment below).
|
|
162
|
+
*/
|
|
163
|
+
lastExited: ExitedSession | null = null;
|
|
164
|
+
/**
|
|
165
|
+
* GRA-96: null when the app's `hello.protocol` matches `PROTOCOL_VERSION`,
|
|
166
|
+
* otherwise the sentence `porthole_status` reports verbatim — set once,
|
|
167
|
+
* in `connect()`'s hello handler, right where `hello` itself is set, so it
|
|
168
|
+
* is never stale relative to whichever `hello` is currently held. Kept
|
|
169
|
+
* separate from `lastError`: that field means "the socket or a request
|
|
170
|
+
* failed", this one means "the socket and the handshake both succeeded and
|
|
171
|
+
* the two sides still cannot be trusted to agree on the wire format" — a
|
|
172
|
+
* different fact that deserves its own name instead of overloading
|
|
173
|
+
* lastError's "something went wrong" with a case that is not a failure to
|
|
174
|
+
* connect at all.
|
|
175
|
+
*/
|
|
176
|
+
protocolMismatch: string | null = null;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* GRA-53 `#session-writer`: null (persistence off) unless a sessions root
|
|
180
|
+
* is given. `undefined`/omitted is the default on purpose — every existing
|
|
181
|
+
* test in `device.test.ts` constructs a `DeviceClient` with two arguments
|
|
182
|
+
* and auto-answers `hello`, so leaving this off must not start writing
|
|
183
|
+
* real files into whatever directory the test happened to run from. The
|
|
184
|
+
* real MCP server boot path (`index.ts`'s `createPortholeServer`) passes
|
|
185
|
+
* one explicitly.
|
|
186
|
+
*/
|
|
187
|
+
readonly sessions: SessionWriter | null;
|
|
188
|
+
|
|
189
|
+
constructor(
|
|
190
|
+
private readonly host: string,
|
|
191
|
+
readonly port: number,
|
|
192
|
+
sessionsRoot?: string,
|
|
193
|
+
) {
|
|
194
|
+
super();
|
|
195
|
+
this.sessions = sessionsRoot ? new SessionWriter(sessionsRoot) : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
start(): void {
|
|
199
|
+
this.closed = false;
|
|
200
|
+
this.connect();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
stop(): void {
|
|
204
|
+
this.closed = true;
|
|
205
|
+
// Clearing the timeout without nulling the field leaves scheduleReconnect()'s
|
|
206
|
+
// guard (`this.closed || this.reconnectTimer`) permanently true after a later
|
|
207
|
+
// start(): the stale, already-cleared timer looks exactly like a reconnect
|
|
208
|
+
// that is still scheduled, so a subsequent disconnect never retries.
|
|
209
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
210
|
+
this.reconnectTimer = null;
|
|
211
|
+
// GRA-191: close the writer here, synchronously with stop() itself,
|
|
212
|
+
// rather than relying solely on the "close" handler below reacting to
|
|
213
|
+
// socket.destroy() — that handler fires on a later tick (net sockets
|
|
214
|
+
// emit "close" asynchronously), which is exactly the gap that let a
|
|
215
|
+
// still-armed 250ms flush timer survive past a test's own teardown and
|
|
216
|
+
// fire against a sessions root the teardown had already removed
|
|
217
|
+
// (GRA-183's reproduction). close() flushes whatever is queued (which
|
|
218
|
+
// also cancels that timer — see SessionWriter.doFlush()'s own first
|
|
219
|
+
// lines) and marks the writer closed so nothing appended after this
|
|
220
|
+
// point queues into a writer nothing will flush again.
|
|
221
|
+
this.sessions?.close();
|
|
222
|
+
this.socket?.destroy();
|
|
223
|
+
this.socket = null;
|
|
224
|
+
this.setState("disconnected");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private connect(): void {
|
|
228
|
+
if (this.closed || this.socket) return;
|
|
229
|
+
this.setState("connecting");
|
|
230
|
+
|
|
231
|
+
const socket = net.createConnection({ host: this.host, port: this.port });
|
|
232
|
+
socket.setNoDelay(true);
|
|
233
|
+
socket.setEncoding("utf8");
|
|
234
|
+
this.socket = socket;
|
|
235
|
+
|
|
236
|
+
socket.on("connect", () => {
|
|
237
|
+
this.reconnectDelay = RECONNECT_MIN_MS;
|
|
238
|
+
this.lastError = null;
|
|
239
|
+
// Not "connected" yet — GRA-157. The TCP handshake finishing says
|
|
240
|
+
// nothing about whether the far end is a Porthole runtime, let alone
|
|
241
|
+
// which app; hello is what answers that, and it has not been asked
|
|
242
|
+
// yet at this line. Callers that need "connected" is real now get it:
|
|
243
|
+
// the state stays "handshaking" until the block below actually has a
|
|
244
|
+
// Hello in hand.
|
|
245
|
+
this.setState("handshaking");
|
|
246
|
+
// hello doubles as a liveness check and as the timeline's origin.
|
|
247
|
+
this.request<Hello>("hello")
|
|
248
|
+
.then(async (hello) => {
|
|
249
|
+
// Order matters: hello is set before the state change that
|
|
250
|
+
// announces it, so anything reacting to the "state" event (or
|
|
251
|
+
// reading `.hello` right after seeing state flip to "connected")
|
|
252
|
+
// never observes "connected" with `hello` still null. setState()
|
|
253
|
+
// also asserts this itself, so a future edit that reordered these
|
|
254
|
+
// two lines would fail loudly instead of reintroducing the race.
|
|
255
|
+
this.hello = hello;
|
|
256
|
+
// GRA-96: computed right where `hello` is set, not deferred to
|
|
257
|
+
// whichever tool asks later — a caller reading `protocolMismatch`
|
|
258
|
+
// right after the "hello" event below always sees the answer for
|
|
259
|
+
// the `hello` it just received, never a stale one from a previous
|
|
260
|
+
// connection. Refusal, not a thrown error: the socket is fine and
|
|
261
|
+
// the app really did answer, so the rest of the surface (findings,
|
|
262
|
+
// timeline, …) still works for whatever it can, and this is the
|
|
263
|
+
// one specific, actionable fact layered on top (GRA-96 AC1/AC2).
|
|
264
|
+
// Synchronous, like the assignment above — that is what lets it
|
|
265
|
+
// sit between `this.hello = hello` and the emit below without
|
|
266
|
+
// reopening GRA-191's race (see that emit's own comment).
|
|
267
|
+
this.protocolMismatch =
|
|
268
|
+
hello.protocol === PROTOCOL_VERSION
|
|
269
|
+
? null
|
|
270
|
+
: `The app is speaking protocol ${hello.protocol}; this server understands protocol ` +
|
|
271
|
+
`${PROTOCOL_VERSION}. Update the app's Porthole runtime dependency to a version that ` +
|
|
272
|
+
`speaks protocol ${PROTOCOL_VERSION}, or pin the npm package this MCP server runs from ` +
|
|
273
|
+
`(in .mcp.json) to the version that matches the app.`;
|
|
274
|
+
// GRA-191: `this.hello = hello` above and this emit must stay
|
|
275
|
+
// exactly this close together, with nothing between them that can
|
|
276
|
+
// yield to the event loop. GRA-53 used to put
|
|
277
|
+
// `await this.sessions.open(hello)` right here, reasoning that
|
|
278
|
+
// "connected" — and the events a caller might start sending the
|
|
279
|
+
// instant it sees that state — should never arrive before there
|
|
280
|
+
// was somewhere for `append()` to put them. That reasoning was
|
|
281
|
+
// right about `append()` and wrong about this emit: `open()` is
|
|
282
|
+
// real disk I/O (mkdir/readFile/writeFile/enforceRetention), and
|
|
283
|
+
// awaiting it here opened a window in which `timeline.ts`'s
|
|
284
|
+
// "hello" listener — the thing that clears its ring for a new
|
|
285
|
+
// session — had not run yet, so an event arriving in that window
|
|
286
|
+
// got pushed onto the ring first and was wiped a moment later when
|
|
287
|
+
// the delayed "hello" finally fired (GRA-183's reproduction).
|
|
288
|
+
// Moving the emit up here removes the window instead of narrowing
|
|
289
|
+
// it: every "hello" listener runs to completion before this
|
|
290
|
+
// function's own next line ever executes, so nothing can land
|
|
291
|
+
// between "the ring has cleared for this session" and "the ring
|
|
292
|
+
// is accepting this session's events" — there is no gap for
|
|
293
|
+
// anything to land in. Do not put an `await` of any kind between
|
|
294
|
+
// the assignment above and this emit — that is the exact mistake
|
|
295
|
+
// GRA-191 is about. `sessions.open()` still runs, just after, and
|
|
296
|
+
// `SessionWriter.append()` now queues anything that arrives while
|
|
297
|
+
// it is still in flight rather than dropping it (see sessions.ts),
|
|
298
|
+
// so nothing below this line depends on `open()` having already
|
|
299
|
+
// resolved.
|
|
300
|
+
this.emit("hello", hello);
|
|
301
|
+
// GRA-53 `#session-writer`: opens (or resumes) the on-disk session
|
|
302
|
+
// for this identity. Awaited before `setState("connected")` below
|
|
303
|
+
// — not before the emit above any more — so "connected" still
|
|
304
|
+
// means what GRA-157's tests pin (a writer whose directory is
|
|
305
|
+
// guaranteed to exist by the time a caller sees that state)
|
|
306
|
+
// without that guarantee costing the emit its synchronicity.
|
|
307
|
+
if (this.sessions) await this.sessions.open(hello);
|
|
308
|
+
this.setState("connected");
|
|
309
|
+
})
|
|
310
|
+
.catch((error: Error) => {
|
|
311
|
+
// The socket is still open and still usable — only the hello
|
|
312
|
+
// round-trip failed (most likely its own 5s timeout, if the far
|
|
313
|
+
// end accepted the TCP connection but never speaks the protocol).
|
|
314
|
+
// Staying in "handshaking" rather than falling back to "connected"
|
|
315
|
+
// is the point of this whole change; there is deliberately no
|
|
316
|
+
// retry here, since request() already gives every other method
|
|
317
|
+
// the same 5s timeout and nothing about hello is special enough to
|
|
318
|
+
// loop on its own.
|
|
319
|
+
this.lastError = error.message;
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
socket.on("data", (chunk: string) => this.onData(chunk));
|
|
324
|
+
|
|
325
|
+
socket.on("error", (error: Error) => {
|
|
326
|
+
this.lastError = error.message;
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
socket.on("close", () => {
|
|
330
|
+
this.socket = null;
|
|
331
|
+
// GRA-53: flush promptly on disconnect rather than waiting for the
|
|
332
|
+
// interval timer — a killed app or a dropped socket is exactly the
|
|
333
|
+
// moment nothing else will prompt a flush for a while, and the whole
|
|
334
|
+
// point of writing to disk is that this data survives the socket
|
|
335
|
+
// going away. Deliberately NOT sessions.close()/finalize: a reconnect
|
|
336
|
+
// that gets the same hello back (open()'s idempotent-by-identity
|
|
337
|
+
// check) resumes appending to this same file, so the session directory
|
|
338
|
+
// itself is left exactly as GRA-163 leaves the ring — kept, not
|
|
339
|
+
// cleared, until a genuinely new hello says otherwise.
|
|
340
|
+
void this.sessions?.flush();
|
|
341
|
+
// GRA-163: captured before `hello` is cleared below, and only when
|
|
342
|
+
// there was one — a socket that closes mid-handshake (this.hello
|
|
343
|
+
// still null) never had a confirmed session to record, and recording
|
|
344
|
+
// one here would overwrite the real last-exited process with nothing.
|
|
345
|
+
if (this.hello) {
|
|
346
|
+
this.lastExited = { hello: this.hello, disconnectedAt: Date.now() };
|
|
347
|
+
}
|
|
348
|
+
this.hello = null;
|
|
349
|
+
// GRA-96: cleared with `hello`, for the same reason — a mismatch is a
|
|
350
|
+
// fact about the `hello` that produced it, and once that `hello` is
|
|
351
|
+
// gone (a new connection will get its own, possibly no longer
|
|
352
|
+
// mismatched) there is nothing left for this to still be true about.
|
|
353
|
+
this.protocolMismatch = null;
|
|
354
|
+
this.failPending("device disconnected");
|
|
355
|
+
this.setState("disconnected");
|
|
356
|
+
this.scheduleReconnect();
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private scheduleReconnect(): void {
|
|
361
|
+
if (this.closed || this.reconnectTimer) return;
|
|
362
|
+
this.reconnectTimer = setTimeout(() => {
|
|
363
|
+
this.reconnectTimer = null;
|
|
364
|
+
this.connect();
|
|
365
|
+
}, this.reconnectDelay);
|
|
366
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private onData(chunk: string): void {
|
|
370
|
+
this.buffer += chunk;
|
|
371
|
+
let newline = this.buffer.indexOf("\n");
|
|
372
|
+
while (newline >= 0) {
|
|
373
|
+
const line = this.buffer.slice(0, newline).trim();
|
|
374
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
375
|
+
if (line.length > 0) this.onLine(line);
|
|
376
|
+
newline = this.buffer.indexOf("\n");
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private onLine(line: string): void {
|
|
381
|
+
let frame: Response | DeviceEvent;
|
|
382
|
+
try {
|
|
383
|
+
frame = JSON.parse(line);
|
|
384
|
+
} catch {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if ("event" in frame) {
|
|
389
|
+
// GRA-53: queued, not written — SessionWriter.append() only ever
|
|
390
|
+
// pushes to its own in-memory array and arms a flush timer, so this
|
|
391
|
+
// line does not touch the filesystem and cannot be the reason a frame
|
|
392
|
+
// is processed late. GRA-191: append() no longer needs the session's
|
|
393
|
+
// directory to already exist to accept this — persistence being off
|
|
394
|
+
// entirely, or a writer `close()` already closed, are the only cases
|
|
395
|
+
// it still silently drops; an event that arrives while `open()` is
|
|
396
|
+
// still awaiting disk I/O (or has not even been called yet — see
|
|
397
|
+
// `connect()`'s "hello" handler) is queued and written once the
|
|
398
|
+
// directory exists.
|
|
399
|
+
this.sessions?.append(frame as SessionEvent);
|
|
400
|
+
this.emit("event", frame);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const waiter = this.pending.get(frame.id);
|
|
405
|
+
if (!waiter) return;
|
|
406
|
+
this.pending.delete(frame.id);
|
|
407
|
+
clearTimeout(waiter.timer);
|
|
408
|
+
if (frame.ok) waiter.resolve(frame.result);
|
|
409
|
+
else waiter.reject(new Error(frame.error ?? "unknown device error"));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private failPending(reason: string): void {
|
|
413
|
+
for (const [, waiter] of this.pending) {
|
|
414
|
+
clearTimeout(waiter.timer);
|
|
415
|
+
waiter.reject(new Error(reason));
|
|
416
|
+
}
|
|
417
|
+
this.pending.clear();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private setState(state: ConnectionState): void {
|
|
421
|
+
// GRA-157 AC1: "connected" implies `hello` is non-null, enforced here —
|
|
422
|
+
// the one place `state` is actually assigned — rather than left for
|
|
423
|
+
// every reader to remember. If this throws, the bug is in this file
|
|
424
|
+
// (most likely connect()'s hello handler setting state before hello),
|
|
425
|
+
// never in whatever asked for the transition.
|
|
426
|
+
if (state === "connected" && this.hello === null) {
|
|
427
|
+
throw new Error(
|
|
428
|
+
"DeviceClient invariant violated: cannot enter state 'connected' with hello still null",
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
if (this.state === state) return;
|
|
432
|
+
this.state = state;
|
|
433
|
+
this.emit("state", state);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
request<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
|
437
|
+
const socket = this.socket;
|
|
438
|
+
// "handshaking" is allowed here on purpose: it is the exact state hello
|
|
439
|
+
// itself is sent in (see connect()'s socket.on("connect") above, which
|
|
440
|
+
// sets "handshaking" and then calls request<Hello>("hello") next) —
|
|
441
|
+
// gating on "connected" alone would make sending hello reject itself.
|
|
442
|
+
// Only "connecting" (TCP handshake still in flight — this.socket exists
|
|
443
|
+
// but has not fired "connect" yet) and "disconnected" have no usable
|
|
444
|
+
// socket to write a frame to.
|
|
445
|
+
//
|
|
446
|
+
// GRA-162 QA: this used to spell the same condition out longhand as
|
|
447
|
+
// `this.state !== "handshaking" && this.state !== "connected"`, which is
|
|
448
|
+
// isAttached() negated (De Morgan's) but written by hand instead of
|
|
449
|
+
// through it. That made this a sixth silent site the AC 2 probe did not
|
|
450
|
+
// catch: a `!==` pair against two literals still compiles unchanged when
|
|
451
|
+
// the union grows, and a future state would fall through to "not
|
|
452
|
+
// attached" and reject every request() call with the not-connected
|
|
453
|
+
// message even while the socket was genuinely live. Routing through
|
|
454
|
+
// isAttached() puts this choke point behind the same never-guarded
|
|
455
|
+
// switch as the rest, so a new state fails `tsc` here too instead of
|
|
456
|
+
// silently rejecting live traffic.
|
|
457
|
+
if (!socket || !isAttached(this.state)) {
|
|
458
|
+
return Promise.reject(new Error(this.notConnectedMessage()));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const id = this.nextId++;
|
|
462
|
+
// Strip undefined so optional tool arguments do not become JSON nulls that
|
|
463
|
+
// the Kotlin side would have to special-case.
|
|
464
|
+
const cleaned: Record<string, unknown> = {};
|
|
465
|
+
for (const [key, value] of Object.entries(params)) {
|
|
466
|
+
if (value !== undefined) cleaned[key] = value;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return new Promise<T>((resolve, reject) => {
|
|
470
|
+
const timer = setTimeout(() => {
|
|
471
|
+
this.pending.delete(id);
|
|
472
|
+
reject(new Error(`device did not answer '${method}' within ${REQUEST_TIMEOUT_MS}ms`));
|
|
473
|
+
}, REQUEST_TIMEOUT_MS);
|
|
474
|
+
|
|
475
|
+
this.pending.set(id, {
|
|
476
|
+
resolve: resolve as (value: unknown) => void,
|
|
477
|
+
reject,
|
|
478
|
+
timer,
|
|
479
|
+
});
|
|
480
|
+
socket.write(JSON.stringify({ id, method, params: cleaned }) + "\n");
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* The disconnected/handshaking half of what every tool says about the
|
|
486
|
+
* connection, written once instead of separately by porthole_status,
|
|
487
|
+
* findings, and whichever tool asks next (GRA-157 AC3). Returns null when
|
|
488
|
+
* `state` is "connected", since `hello` is guaranteed non-null there (see
|
|
489
|
+
* setState() above) and what to say about a live connection differs by
|
|
490
|
+
* caller — porthole_status names the collectors, findings talks about the
|
|
491
|
+
* buffer — so that half stays with each tool.
|
|
492
|
+
*
|
|
493
|
+
* The switch is exhaustive on purpose, with a compiled-in `never` check
|
|
494
|
+
* instead of a `default` that quietly falls through: a fifth
|
|
495
|
+
* ConnectionState added later without a case here fails `tsc`, in this one
|
|
496
|
+
* place, rather than silently being treated as either "connected" or the
|
|
497
|
+
* wall. Everywhere else asks this method instead of re-deriving the answer
|
|
498
|
+
* from `state` and `hello` by hand, which is the actual fix GRA-157 is
|
|
499
|
+
* about. (GRA-162: this used to say it was "deliberately the only place in
|
|
500
|
+
* the package with that check" — it no longer is. QA counted eight sites
|
|
501
|
+
* outside this file that read `device.state === "…"` directly, which
|
|
502
|
+
* `tsc` does not flag when a state is added because `===` against a string
|
|
503
|
+
* literal just evaluates false for anything new. `isAttached()`,
|
|
504
|
+
* `isConnected()` and `isHandshaking()` below give those call sites the
|
|
505
|
+
* same guarantee this switch has always had, instead of leaving them to
|
|
506
|
+
* reinvent it inconsistently or not at all.)
|
|
507
|
+
*/
|
|
508
|
+
/**
|
|
509
|
+
* GRA-163 QA round 1: this used to append an "exited process" sentence of
|
|
510
|
+
* its own (`withExitedSessionNote()`, now removed) whenever `lastExited`
|
|
511
|
+
* was set. That sentence unconditionally said "whatever is still buffered
|
|
512
|
+
* is from X" — true on the branch that has a non-empty ring, false on the
|
|
513
|
+
* branch that does not (the empty-ring-plus-`lastExited` case: a process
|
|
514
|
+
* reconnects, clears the ring on its own `hello`, then dies before
|
|
515
|
+
* emitting anything), because this method has no way to know which one it
|
|
516
|
+
* is being asked from — `pendingMessage()` only ever sees `this.state`
|
|
517
|
+
* and `this.lastExited`, never `timeline.buffer().length`. Two more
|
|
518
|
+
* faults rode along with that one: `porthole_status` calls
|
|
519
|
+
* `exitedProcessField()` unconditionally while `findings`/
|
|
520
|
+
* `what_was_happening` only called it from their non-empty-ring branch,
|
|
521
|
+
* so the same state produced a payload with `exitedProcess` on one tool
|
|
522
|
+
* and without it on another — and the prose was folded into this string
|
|
523
|
+
* while the structured field lived in index.ts, so the two could disagree
|
|
524
|
+
* even about a single tool's own single answer.
|
|
525
|
+
*
|
|
526
|
+
* The fix moves all of it to index.ts's `exitedProcessNotice()`, called
|
|
527
|
+
* from every branch of every tool that might describe ring content,
|
|
528
|
+
* because index.ts is the one place that actually knows whether the ring
|
|
529
|
+
* it is about to describe is empty. This method goes back to answering
|
|
530
|
+
* exactly what its name says: how to reconnect, or that the handshake is
|
|
531
|
+
* still pending. Nothing else.
|
|
532
|
+
*/
|
|
533
|
+
pendingMessage(): string | null {
|
|
534
|
+
switch (this.state) {
|
|
535
|
+
case "disconnected":
|
|
536
|
+
case "connecting":
|
|
537
|
+
return this.notConnectedMessage();
|
|
538
|
+
case "handshaking":
|
|
539
|
+
return HANDSHAKE_PENDING_MESSAGE;
|
|
540
|
+
case "connected":
|
|
541
|
+
return null;
|
|
542
|
+
default: {
|
|
543
|
+
const exhaustive: never = this.state;
|
|
544
|
+
throw new Error(`DeviceClient: unhandled ConnectionState '${exhaustive as string}'`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
notConnectedMessage(): string {
|
|
550
|
+
return [
|
|
551
|
+
`Not connected to the app on ${this.host}:${this.port}.`,
|
|
552
|
+
this.lastError ? `Last socket error: ${this.lastError}.` : null,
|
|
553
|
+
"Check, in order:",
|
|
554
|
+
" 1. the debug build is running on the device (the porthole starts with the process)",
|
|
555
|
+
" 2. the adb bridge is up: 'adb forward tcp:PORT tcp:PORT', which",
|
|
556
|
+
" 'porthole ui' and './gradlew portholeConnect' both do for you",
|
|
557
|
+
` 3. nothing else on this machine is holding ${this.port}`,
|
|
558
|
+
]
|
|
559
|
+
.filter(Boolean)
|
|
560
|
+
.join("\n");
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// --- GRA-162: exhaustive readers of a bare ConnectionState ------------------
|
|
565
|
+
//
|
|
566
|
+
// Free functions, not methods, because every call site below holds a
|
|
567
|
+
// ConnectionState value (`device.state`, or one carried on an event/message)
|
|
568
|
+
// rather than a DeviceClient to ask. Each is a switch with the same
|
|
569
|
+
// compiled-in `never` guard as pendingMessage() above: adding a fifth
|
|
570
|
+
// ConnectionState without extending a case list here fails `tsc` at that
|
|
571
|
+
// list, not silently at nothing. Three functions rather than one because the
|
|
572
|
+
// call sites genuinely want three different questions answered, and a single
|
|
573
|
+
// helper returning a wider type would just move the "did I handle the new
|
|
574
|
+
// case" judgement call to every caller instead of to the compiler here.
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* The "loose" sense `findings`, `porthole_status` and `what_was_happening`
|
|
578
|
+
* use: the socket is up, whether or not `hello` has landed. True for
|
|
579
|
+
* "handshaking" and "connected"; false for "connecting" and "disconnected".
|
|
580
|
+
*/
|
|
581
|
+
export function isAttached(state: ConnectionState): boolean {
|
|
582
|
+
switch (state) {
|
|
583
|
+
case "handshaking":
|
|
584
|
+
case "connected":
|
|
585
|
+
return true;
|
|
586
|
+
case "connecting":
|
|
587
|
+
case "disconnected":
|
|
588
|
+
return false;
|
|
589
|
+
default: {
|
|
590
|
+
const exhaustive: never = state;
|
|
591
|
+
throw new Error(`DeviceClient: unhandled ConnectionState '${exhaustive as string}'`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* The "strict" sense: `hello` has actually landed. True only for
|
|
598
|
+
* "connected" — see setState()'s invariant above, which makes that the only
|
|
599
|
+
* state in which `hello` is guaranteed non-null.
|
|
600
|
+
*/
|
|
601
|
+
export function isConnected(state: ConnectionState): boolean {
|
|
602
|
+
switch (state) {
|
|
603
|
+
case "connected":
|
|
604
|
+
return true;
|
|
605
|
+
case "connecting":
|
|
606
|
+
case "handshaking":
|
|
607
|
+
case "disconnected":
|
|
608
|
+
return false;
|
|
609
|
+
default: {
|
|
610
|
+
const exhaustive: never = state;
|
|
611
|
+
throw new Error(`DeviceClient: unhandled ConnectionState '${exhaustive as string}'`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* True only while the handshake is in flight: the socket is up, `hello` has
|
|
618
|
+
* not landed. Named separately from isAttached()/isConnected() because
|
|
619
|
+
* several call sites want to say something specific about the handshake
|
|
620
|
+
* window rather than lump it in with either "attached" or "not yet".
|
|
621
|
+
*/
|
|
622
|
+
export function isHandshaking(state: ConnectionState): boolean {
|
|
623
|
+
switch (state) {
|
|
624
|
+
case "handshaking":
|
|
625
|
+
return true;
|
|
626
|
+
case "connecting":
|
|
627
|
+
case "connected":
|
|
628
|
+
case "disconnected":
|
|
629
|
+
return false;
|
|
630
|
+
default: {
|
|
631
|
+
const exhaustive: never = state;
|
|
632
|
+
throw new Error(`DeviceClient: unhandled ConnectionState '${exhaustive as string}'`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|