omp-mobile 0.4.0 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-mobile",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "The computer side of OMP Mobile: runs oh-my-pi sessions and serves them to the phone app over Tailscale.",
5
5
  "keywords": [
6
6
  "mobile",
@@ -33,7 +33,7 @@ const POLL_MS = 1_500;
33
33
  /** How long the code stays up without a phone connecting. */
34
34
  const SHOW_MS = 5 * 60_000;
35
35
  /** How often the session's takeover request is looked for, and how stale one may be. */
36
- const TAKEOVER_POLL_MS = 1_000;
36
+ const TAKEOVER_POLL_MS = 500;
37
37
  const TAKEOVER_MAX_AGE_MS = 60_000;
38
38
  /** How long a running turn gets to stop before omp closes anyway. */
39
39
  const TAKEOVER_IDLE_WAIT_MS = 5_000;
@@ -142,7 +142,15 @@ export class RpcSession extends EventEmitter {
142
142
  stdio: ["pipe", "pipe", "pipe"],
143
143
  });
144
144
  this.child = child;
145
- child.on("error", (error) => this.#fail(error));
145
+ // A process that never started (missing binary, a cwd that was removed)
146
+ // emits `error` and no `exit`, so `closed` never comes: without its own
147
+ // signal the `ready` wait would sit out the whole start timeout.
148
+ child.on("error", (error) => {
149
+ this.closed = true;
150
+ this.lastError = error;
151
+ this.emit("start-error", error);
152
+ this.#fail(error);
153
+ });
146
154
  child.on("exit", (code, signal) => {
147
155
  this.closed = true;
148
156
  // Nothing can answer the dialogs of a process that is gone.
@@ -157,7 +165,10 @@ export class RpcSession extends EventEmitter {
157
165
  });
158
166
  const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
159
167
  const ready = new Promise((resolve, reject) => {
160
- const timer = setTimeout(() => reject(new Error("omp RPC did not become ready")), START_TIMEOUT_MS);
168
+ const timer = setTimeout(
169
+ () => reject(new Error(`omp RPC did not become ready in ${Math.round(START_TIMEOUT_MS / 1000)}s: ${this.stderr.trim().slice(-500) || "no output"}`)),
170
+ START_TIMEOUT_MS,
171
+ );
161
172
  const onReady = (message) => {
162
173
  clearTimeout(timer);
163
174
  this.off("ready", onReady);
@@ -168,6 +179,14 @@ export class RpcSession extends EventEmitter {
168
179
  clearTimeout(timer);
169
180
  reject(new Error(`omp exited before ready: ${this.stderr.slice(-500)}`));
170
181
  });
182
+ // `start()` is called from the daemon's request path: the reason a process
183
+ // cannot start (no omp on PATH, the session's directory is gone) has to
184
+ // reach the phone instead of a generic timeout.
185
+ this.once("start-error", (error) => {
186
+ clearTimeout(timer);
187
+ this.off("ready", onReady);
188
+ reject(new Error(`omp could not start in ${this.cwd}: ${error?.message ?? String(error)}`));
189
+ });
171
190
  });
172
191
  lines.on("line", (line) => this.#onLine(line));
173
192
  const hello = await ready;
package/src/server.mjs CHANGED
@@ -43,6 +43,18 @@ const STATE_EVENTS = new Set([
43
43
  "todo_auto_clear",
44
44
  "goal_updated",
45
45
  ]);
46
+ /**
47
+ * How long OMP's session directory and owner locks are reused for, in ms.
48
+ * The phone probes every 15 s; without this every probe would rescan every
49
+ * session file (hundreds, often a gigabyte) and spawn `lsof` again.
50
+ */
51
+ const SESSION_SCAN_TTL_MS = 3_000;
52
+ const OWNERS_TTL_MS = 1_500;
53
+ /** WebSocket keepalive: a protocol ping plus a `ping` frame, and a dead peer after one missed beat. */
54
+ const HEARTBEAT_MS = 20_000;
55
+ /** A live omp nobody has looked at for this long is closed; the next open resumes it. */
56
+ const IDLE_SESSION_MS = 30 * 60_000;
57
+ const REAP_INTERVAL_MS = 60_000;
46
58
  /** Image types a phone may attach to a prompt: what the camera and the photo picker produce. */
47
59
  const PROMPT_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
48
60
  /** At most this many images per prompt, each at most this many decoded bytes. */
@@ -75,9 +87,11 @@ export function promptImages(value) {
75
87
  /**
76
88
  * What this daemon supports beyond protocol 1, for an app to offer only what
77
89
  * the computer it talks to can do. `prompt-images`: `prompt` frames carry `images`;
78
- * `takeover`: `POST /sessions/:id/takeover` continues a session another omp process holds.
90
+ * `takeover`: `POST /sessions/:id/takeover` continues a session another omp process holds;
91
+ * `heartbeat`: session sockets get a `ping` frame every 20 s, so the app can
92
+ * treat a silent link as gone.
79
93
  */
80
- const FEATURES = ["prompt-images", "takeover"];
94
+ const FEATURES = ["prompt-images", "takeover", "heartbeat"];
81
95
  /** Who a phone continues a session on, as the terminal it leaves is told. */
82
96
  const DEFAULT_DEVICE = "OMP Mobile";
83
97
  /** Close code after an `open_failed` frame: the session socket opened only to say why the session could not. */
@@ -249,6 +263,27 @@ function saveRegistry(path, registry) {
249
263
  writeFileSync(path, `${JSON.stringify(Object.fromEntries(registry), null, 2)}\n`, { mode: 0o600 });
250
264
  }
251
265
 
266
+ /**
267
+ * The directory an omp starts in, recreated when it has been removed (a
268
+ * session in /tmp outlives its directory). Answers a 410 the phone can show
269
+ * when it cannot be recreated (a parent that is a file, no permission).
270
+ * @param {string} cwd
271
+ * @returns {string}
272
+ */
273
+ export function ensureWorkingDirectory(cwd) {
274
+ try {
275
+ if (statSync(cwd).isDirectory()) return cwd;
276
+ } catch {
277
+ // Missing: recreate it below.
278
+ }
279
+ try {
280
+ mkdirSync(cwd, { recursive: true });
281
+ return cwd;
282
+ } catch {
283
+ throw httpError(410, `the session's working directory is gone and cannot be recreated: ${cwd}`);
284
+ }
285
+ }
286
+
252
287
  function storedSummary(stored) {
253
288
  return {
254
289
  id: stored.id,
@@ -388,6 +423,8 @@ class SessionEntry {
388
423
  */
389
424
  this.onBroadcast = undefined;
390
425
  this.closed = false;
426
+ /** When the last phone left; undefined while one is connected. The reaper reads it. */
427
+ this.idleSince = undefined;
391
428
  }
392
429
 
393
430
  async hydrate() {
@@ -521,6 +558,14 @@ class Daemon {
521
558
  this.streams = new Set();
522
559
  /** OMP's own sessions as last listed, for resuming one the app picked. @type {Map<string, import("./omp-sessions.mjs").OmpSession>} */
523
560
  this.ompSessions = new Map();
561
+ /** The last full scan of OMP's session directory, reused within `SESSION_SCAN_TTL_MS`. */
562
+ this.sessionScan = { at: 0, list: /** @type {import("./omp-sessions.mjs").OmpSession[]} */ ([]), pending: undefined };
563
+ /** The last `lsof` of the owner locks, pending or settled, reused within `OWNERS_TTL_MS`. */
564
+ this.ownerLookup = { at: 0, owners: /** @type {Promise<Map<string, number> | undefined> | undefined} */ (undefined) };
565
+ /** @type {ReturnType<typeof setInterval> | undefined} */
566
+ this.heartbeat = undefined;
567
+ /** @type {ReturnType<typeof setInterval> | undefined} */
568
+ this.reaper = undefined;
524
569
  /** Resumes in flight, so two opens of one session start one process. @type {Map<string, Promise<SessionEntry>>} */
525
570
  this.starting = new Map();
526
571
  /** Takeovers in flight, so two phones asking at once ask the owner once. @type {Map<string, Promise<object>>} */
@@ -657,6 +702,7 @@ class Daemon {
657
702
  const live = this.sessions.get(id);
658
703
  if (live !== undefined) this.attention.forget(live);
659
704
  live?.close();
705
+ this.forgetOwners();
660
706
  this.sessions.delete(id);
661
707
  this.registry.delete(id);
662
708
  saveRegistry(this.options.registryFile, this.registry);
@@ -685,10 +731,7 @@ class Daemon {
685
731
  .filter((stored) => !known.has(stored.id))
686
732
  .map((stored) => storedSummary(stored));
687
733
  for (const session of dormant) known.add(session.id);
688
- const [found, owners] = await Promise.all([
689
- listOmpSessions(join(this.options.ompHome, "agent", "sessions"), { limit: OMP_SESSION_LIMIT }),
690
- this.owners(),
691
- ]);
734
+ const [found, owners] = await Promise.all([this.scanOmpSessions(), this.owners()]);
692
735
  this.ompSessions = new Map(found.map((session) => [session.id, session]));
693
736
  const others = found
694
737
  .filter((session) => !known.has(session.id))
@@ -713,9 +756,40 @@ class Daemon {
713
756
  return { pid, busy: typeof sessionFile === "string" && sessionBusy(sessionFile), terminal: info?.tty !== undefined };
714
757
  }
715
758
 
716
- /** Which sessions some omp process holds open (`undefined`: cannot tell). */
759
+ /**
760
+ * OMP's sessions, rescanned at most every `SESSION_SCAN_TTL_MS`: a phone's
761
+ * probes ask for the same list every few seconds, and reading the first
762
+ * 512 KB of up to 50 of hundreds of files is the daemon's most expensive read.
763
+ */
764
+ scanOmpSessions() {
765
+ const scan = this.sessionScan;
766
+ if (Date.now() - scan.at < SESSION_SCAN_TTL_MS) return Promise.resolve(scan.list);
767
+ if (scan.pending !== undefined) return scan.pending;
768
+ scan.pending = listOmpSessions(join(this.options.ompHome, "agent", "sessions"), { limit: OMP_SESSION_LIMIT }).then(
769
+ (list) => {
770
+ this.sessionScan = { at: Date.now(), list, pending: undefined };
771
+ return list;
772
+ },
773
+ (error) => {
774
+ scan.pending = undefined;
775
+ throw error;
776
+ },
777
+ );
778
+ return scan.pending;
779
+ }
780
+
781
+ /** Which sessions some omp process holds open (`undefined`: cannot tell). Cached for `OWNERS_TTL_MS`. */
717
782
  owners() {
718
- return sessionOwners(join(this.options.ompHome, "run", "session-owners"));
783
+ const lookup = this.ownerLookup;
784
+ if (lookup.owners !== undefined && Date.now() - lookup.at < OWNERS_TTL_MS) return lookup.owners;
785
+ const owners = sessionOwners(join(this.options.ompHome, "run", "session-owners"));
786
+ this.ownerLookup = { at: Date.now(), owners };
787
+ return owners;
788
+ }
789
+
790
+ /** The owner locks changed (a takeover, an omp closing): the next look must not reuse the cache. */
791
+ forgetOwners() {
792
+ this.ownerLookup = { at: 0, owners: undefined };
719
793
  }
720
794
 
721
795
  remember(entry) {
@@ -754,14 +828,20 @@ class Daemon {
754
828
  if (!existsSync(stored.sessionFile)) throw httpError(410, "session file is gone");
755
829
  // A second omp on the same file would fork it into a new one; the session
756
830
  // stays with the process that has it (a terminal) until that one exits.
831
+ // This comes before the directory check: a held session is a takeover, not
832
+ // a broken one, and the phone's Take over button needs the 409.
757
833
  const owner = (await this.owners())?.get(id);
758
834
  if (owner !== undefined) {
759
835
  throw httpError(409, `This session is open in another omp process (pid ${owner}). Close it there to continue here.`, {
760
836
  owner: await this.ownerOf(owner, stored.sessionFile),
761
837
  });
762
838
  }
839
+ // OMP refuses to start in a directory that no longer exists (a session in
840
+ // /tmp whose directory was cleaned up). Recreate it here instead of letting
841
+ // the start wait out its timeout and answer with a bare 500.
842
+ const cwd = ensureWorkingDirectory(stored.cwd);
763
843
  const rpc = new RpcSession({
764
- cwd: stored.cwd,
844
+ cwd,
765
845
  ompBin: this.options.omp,
766
846
  resume: stored.sessionFile,
767
847
  autoApprove: this.options.autoApprove,
@@ -832,6 +912,8 @@ class Daemon {
832
912
  if (forks.length > 0) this.log(new Error(`taking over ${id} from pid ${pid} left forked session files: ${forks.join(", ")}`));
833
913
  outcome = { pid, via: left.via, waitedMs: left.waitedMs, busy, forks };
834
914
  }
915
+ // The owner left: the cached lock map is stale and would refuse this open.
916
+ this.forgetOwners();
835
917
  const entry = await this.ensureLive(id);
836
918
  return { ...entry.snapshot(), takeover: outcome };
837
919
  }
@@ -875,29 +957,62 @@ class Daemon {
875
957
  return;
876
958
  }
877
959
  this.noteClient(req);
878
- void this.ensureLive(decodeURIComponent(match[1]))
879
- .then((entry) => {
880
- this.wsServer.handleUpgrade(req, socket, head, (ws) => {
881
- entry.clients.add(ws);
882
- ws.send(JSON.stringify({ type: "snapshot", ...entry.snapshot() }));
960
+ const id = decodeURIComponent(match[1]);
961
+ // Finish the handshake before the session is open: a slow omp can take
962
+ // tens of seconds, and a phone's WebSocket client gives up after ~10 s --
963
+ // then retries, starting another omp every time. The snapshot (or
964
+ // `open_failed`) follows on the open socket instead.
965
+ this.wsServer.handleUpgrade(req, socket, head, (ws) => {
966
+ ws.isAlive = true;
967
+ ws.on("pong", () => {
968
+ ws.isAlive = true;
969
+ });
970
+ /** @type {SessionEntry | undefined} */
971
+ let entry;
972
+ /** Frames the phone sent before the session was ready. */
973
+ const early = [];
974
+ ws.on("message", (data) => {
975
+ if (entry === undefined) {
976
+ early.push(data.toString());
977
+ return;
978
+ }
979
+ void this.handleClientMessage(entry, ws, data.toString());
980
+ });
981
+ ws.on("close", () => {
982
+ if (entry !== undefined) this.detach(entry, ws);
983
+ });
984
+ ws.on("error", () => {
985
+ if (entry !== undefined) this.detach(entry, ws);
986
+ });
987
+ void this.ensureLive(id).then(
988
+ (live) => {
989
+ entry = live;
990
+ if (ws.readyState !== 1) return;
991
+ live.clients.add(ws);
992
+ live.idleSince = undefined;
993
+ ws.send(JSON.stringify({ type: "snapshot", ...live.snapshot() }));
883
994
  // A dialog opened before this client connected (it may have come from a notification tap).
884
- for (const request of entry.openRequests()) ws.send(JSON.stringify({ type: "approval", request }));
885
- ws.on("message", (data) => void this.handleClientMessage(entry, ws, data.toString()));
886
- ws.on("close", () => entry.clients.delete(ws));
887
- ws.on("error", () => entry.clients.delete(ws));
888
- });
889
- })
890
- .catch((error) => {
891
- const status = typeof error?.status === "number" ? error.status : 500;
892
- if (status >= 500) this.log(error);
893
- // A browser-style client only sees that the upgrade failed, never why. Finish
894
- // the handshake instead, say why the session cannot open, and close: the app
895
- // shows the reason and stops retrying what a retry cannot fix.
896
- this.wsServer.handleUpgrade(req, socket, head, (ws) => {
995
+ for (const request of live.openRequests()) ws.send(JSON.stringify({ type: "approval", request }));
996
+ for (const data of early) void this.handleClientMessage(live, ws, data);
997
+ early.length = 0;
998
+ },
999
+ (error) => {
1000
+ const status = typeof error?.status === "number" ? error.status : 500;
1001
+ if (status >= 500) this.log(error);
1002
+ if (ws.readyState !== 1) return;
1003
+ // Say why the session cannot open and close: the app shows the reason
1004
+ // and stops retrying what a retry cannot fix.
897
1005
  ws.send(JSON.stringify({ type: "open_failed", status, ...errorBody(error) }));
898
1006
  ws.close(OPEN_FAILED_CLOSE_CODE, "session did not open");
899
- });
900
- });
1007
+ },
1008
+ );
1009
+ });
1010
+ }
1011
+
1012
+ /** A phone left a session: it counts as idle from now, for the reaper. */
1013
+ detach(entry, ws) {
1014
+ if (!entry.clients.delete(ws)) return;
1015
+ if (entry.clients.size === 0) entry.idleSince = Date.now();
901
1016
  }
902
1017
 
903
1018
  async handleClientMessage(entry, ws, text) {
@@ -996,11 +1111,57 @@ class Daemon {
996
1111
 
997
1112
  listen() {
998
1113
  return new Promise((resolveListen) => {
999
- this.server.listen(this.options.port, this.options.host, () => resolveListen());
1114
+ this.server.listen(this.options.port, this.options.host, () => {
1115
+ this.startTimers();
1116
+ resolveListen();
1117
+ });
1000
1118
  });
1001
1119
  }
1002
1120
 
1121
+ /** Keepalive pings and the idle-session reaper, once the daemon listens. */
1122
+ startTimers() {
1123
+ this.heartbeat = setInterval(() => this.pingClients(), this.options.heartbeatMs ?? HEARTBEAT_MS);
1124
+ this.heartbeat.unref?.();
1125
+ this.reaper = setInterval(() => this.reapIdleSessions(), this.options.reapIntervalMs ?? REAP_INTERVAL_MS);
1126
+ this.reaper.unref?.();
1127
+ }
1128
+
1129
+ /**
1130
+ * Ping every WebSocket and drop the ones that missed the previous beat. A
1131
+ * protocol ping plus a small frame keeps NAT and phone radios from dropping
1132
+ * an idle session silently; the app ignores the frame, older ones too.
1133
+ */
1134
+ pingClients() {
1135
+ const beat = JSON.stringify({ type: "ping", at: Date.now() });
1136
+ for (const ws of this.wsServer.clients) {
1137
+ if (ws.isAlive === false) {
1138
+ ws.terminate();
1139
+ continue;
1140
+ }
1141
+ ws.isAlive = false;
1142
+ try {
1143
+ ws.ping();
1144
+ ws.send(beat);
1145
+ } catch {
1146
+ // Closing; the close handler cleans up.
1147
+ }
1148
+ }
1149
+ }
1150
+
1151
+ /** Close live sessions no phone has looked at for `IDLE_SESSION_MS` (never mid-turn). */
1152
+ reapIdleSessions() {
1153
+ const now = Date.now();
1154
+ for (const [id, entry] of this.sessions) {
1155
+ if (entry.clients.size > 0 || entry.state?.isStreaming === true) continue;
1156
+ if (now - (entry.idleSince ?? entry.updatedAt) < (this.options.idleSessionMs ?? IDLE_SESSION_MS)) continue;
1157
+ entry.close();
1158
+ this.sessions.delete(id);
1159
+ }
1160
+ }
1161
+
1003
1162
  async close() {
1163
+ clearInterval(this.heartbeat);
1164
+ clearInterval(this.reaper);
1004
1165
  for (const end of this.streams) end();
1005
1166
  this.streams.clear();
1006
1167
  // Each omp saves and releases its session before the daemon exits.
package/src/takeover.mjs CHANGED
@@ -24,15 +24,20 @@ import { execFile } from "node:child_process";
24
24
  import { closeSync, constants, openSync, readdirSync, readSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
25
25
  import { basename, dirname, join } from "node:path";
26
26
 
27
- /** How long each step waits before the next, in milliseconds. */
27
+ /**
28
+ * How long each step waits before the next, in milliseconds. The extension
29
+ * path (a terminal with the plugin loaded) answers within a second or two;
30
+ * the signal path is the fallback for an omp that predates the extension, and
31
+ * OMP leaves on SIGINT promptly, so the waits are short rather than generous.
32
+ */
28
33
  export const TAKEOVER_TIMINGS = {
29
- /** For the extension to pick up the request (it polls every second). */
30
- ack: 2_500,
34
+ /** For the extension to pick up the request (it polls every 500 ms). */
35
+ ack: 1_500,
31
36
  /** For an owner whose extension took the request to exit (OMP closes once its session is idle). */
32
37
  extensionExit: 10_000,
33
38
  /** After `SIGINT`, then after `SIGTERM`. */
34
- sigint: 10_000,
35
- sigterm: 10_000,
39
+ sigint: 5_000,
40
+ sigterm: 5_000,
36
41
  poll: 100,
37
42
  };
38
43