safari-mcp 2.19.0 → 2.19.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.
@@ -732,7 +732,16 @@ async function pollForCommands(pollGeneration) {
732
732
  _bridgeFetch(`${bridgeUrl}/heartbeat`, { method: "POST" }).catch(() => {});
733
733
  }, 5000);
734
734
  await previousExecution.catch(() => {});
735
- await executeAndReply(msg, bridgeUrl);
735
+ // 7.9.26: a command whose handleCommand never settles (a tab that never answers,
736
+ // a runtime call that hangs) used to park this loop for good: the beat kept the
737
+ // host believing we were alive, /poll went silent for hours, connectToServer
738
+ // waited on the same claim, and only a Safari restart brought the profile back.
739
+ // Bound it: past the host's hard ceiling we answer with an error, release the
740
+ // claim and keep polling; the abandoned promise may settle later into the void.
741
+ await Promise.race([
742
+ executeAndReply(msg, bridgeUrl),
743
+ _wedgedCommandGuard(msg, bridgeUrl),
744
+ ]);
736
745
  }
737
746
  } finally {
738
747
  if (beat) clearInterval(beat);
@@ -764,6 +773,33 @@ async function pollForCommands(pollGeneration) {
764
773
  }
765
774
  }
766
775
 
776
+ // Ceiling for one command. The host's own hard deadline is max(4×timeout, 180s) and its
777
+ // longest tool timeout is 75s, so nothing legitimate is still awaited after five minutes.
778
+ const _WEDGED_COMMAND_MS = 330000;
779
+ function _wedgedCommandGuard(msg, bridgeUrl) {
780
+ return new Promise((resolve) => {
781
+ setTimeout(async () => {
782
+ console.log("Safari MCP: command wedged, releasing the poll loop", msg && msg.type);
783
+ try {
784
+ await _bridgeFetch(`${bridgeUrl}/result`, {
785
+ method: "POST",
786
+ headers: { "Content-Type": "application/json" },
787
+ body: JSON.stringify({
788
+ type: "response",
789
+ id: msg.id,
790
+ result: null,
791
+ error: `command ${msg.type} did not settle within ${_WEDGED_COMMAND_MS}ms`,
792
+ }),
793
+ signal: AbortSignal.timeout(5000),
794
+ });
795
+ } catch (e) {
796
+ /* the host most likely expired it already */
797
+ }
798
+ resolve();
799
+ }, _WEDGED_COMMAND_MS);
800
+ });
801
+ }
802
+
767
803
  // ========== SHARED: Execute command and send response ==========
768
804
 
769
805
  async function executeAndReply(msg, bridgeUrl = HTTP_URL) {
@@ -2,7 +2,7 @@
2
2
  "manifest_version": 3,
3
3
  "name": "Safari MCP Bridge",
4
4
  "description": "Connects AI agents to Safari — fast JS execution, screenshots, clicks with your real cookies/logins",
5
- "version": "2.10.9",
5
+ "version": "2.10.10",
6
6
  "icons": {
7
7
  "48": "images/icon-48.png",
8
8
  "96": "images/icon-96.png",
package/index.js CHANGED
@@ -908,15 +908,23 @@ try {
908
908
  const heartbeatingWorkerId = _requireActiveHttpWorker(req, res);
909
909
  if (!heartbeatingWorkerId) return;
910
910
  _extensionLastHeartbeat = Date.now();
911
- _extensionLastPollTime = Date.now();
912
911
  // Re-arm in-flight deadlines: the worker told us it is still on the command.
913
912
  // hardDeadline inside armTimer keeps this from extending indefinitely.
913
+ let liveInFlight = false;
914
914
  for (const pending of _pendingRequests.values()) {
915
915
  if (pending.dispatchedWorkerId !== heartbeatingWorkerId) continue;
916
916
  if (!pending.armTimer || Date.now() >= pending.hardDeadline) continue;
917
+ liveInFlight = true;
917
918
  clearTimeout(pending.timer);
918
919
  pending.timer = pending.armTimer();
919
920
  }
921
+ // A beat proves liveness only while the worker is on a command we still wait for.
922
+ // 7.9.26: a worker wedged inside one command (its handleCommand never settled) kept
923
+ // beating every 5s for hours after that command's hard deadline had expired; each
924
+ // beat refreshed the stale clock, so "HTTP poll timeout" never fired, no successor
925
+ // could take the lease, and every tool call died with "Extension timeout". Once
926
+ // nothing dispatched to this worker is in flight, its beats no longer count as polls.
927
+ if (liveInFlight) _extensionLastPollTime = Date.now();
920
928
  res.writeHead(204);
921
929
  res.end();
922
930
  return;
@@ -1219,6 +1227,18 @@ _staleHttpTimer.unref(); // stale-detection must not keep the Node process aliv
1219
1227
 
1220
1228
  // ========== SHARED EXTENSION LOGIC ==========
1221
1229
 
1230
+ // A worker that keeps beating but never finishes a command holds the profile lease
1231
+ // hostage. Releasing it is the same bookkeeping the stale timer does on poll timeout.
1232
+ function _dropWedgedHttpWorker(reason) {
1233
+ if (!_extensionConnected && !_activeHttpWorkerId) return;
1234
+ _extensionConnected = false;
1235
+ _activeHttpWorkerId = "";
1236
+ _connectingHttpWorkers.clear();
1237
+ _reloadHttpWorkerHandoff = null;
1238
+ _drainOnDisconnect(`wedged worker: ${reason}`);
1239
+ console.error(`[Safari MCP] Extension worker dropped as wedged (${reason})`);
1240
+ }
1241
+
1222
1242
  // Drain pending requests and command queue on disconnect — allows fast fallback to AppleScript
1223
1243
  function _drainOnDisconnect(reason) {
1224
1244
  if (process.env.SAFARI_PROFILE) {
@@ -1301,6 +1321,7 @@ function sendToExtension(type, payload = {}, timeoutMs = 30000) {
1301
1321
  // hardDeadline is the ceiling, so a worker that beats forever cannot hang us.
1302
1322
  const hardDeadline = Date.now() + Math.max(timeoutMs * 4, 180000);
1303
1323
  const expire = () => {
1324
+ const pending = _pendingRequests.get(id);
1304
1325
  _pendingRequests.delete(id);
1305
1326
  if (reloadHandoff) _cancelReloadHttpWorkerHandoff(reloadHandoff);
1306
1327
  // Also drop it from the HTTP poll queue — otherwise the extension could poll this command
@@ -1308,6 +1329,15 @@ function sendToExtension(type, payload = {}, timeoutMs = 30000) {
1308
1329
  const qi = _commandQueue.findIndex(c => c.id === id);
1309
1330
  if (qi >= 0) _commandQueue.splice(qi, 1);
1310
1331
  reject(new Error(`Extension timeout after ${timeoutMs}ms`));
1332
+ // The worker took this command and never answered, even past the hard ceiling: it is
1333
+ // wedged (see /heartbeat). Drop its lease now instead of waiting for the stale timer,
1334
+ // so a fresh worker — or Safari re-spawning this one — can connect at once.
1335
+ if (
1336
+ pending && pending.dispatchedWorkerId && pending.dispatchedWorkerId === _activeHttpWorkerId &&
1337
+ Date.now() >= pending.hardDeadline
1338
+ ) {
1339
+ _dropWedgedHttpWorker(`${type} exceeded its hard deadline`);
1340
+ }
1311
1341
  };
1312
1342
  const armTimer = () => setTimeout(expire, Math.min(timeoutMs, Math.max(0, hardDeadline - Date.now())));
1313
1343
  _pendingRequests.set(id, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.19.0",
3
+ "version": "2.19.1",
4
4
  "mcpName": "io.github.achiya-automation/safari-mcp",
5
5
  "description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 97 tools via AppleScript + JavaScript.",
6
6
  "type": "module",