camoufox-cli 0.7.1__tar.gz → 0.7.2__tar.gz

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.

Potentially problematic release.


This version of camoufox-cli might be problematic. Click here for more details.

Files changed (119) hide show
  1. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/PKG-INFO +1 -1
  2. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/package-lock.json +2 -2
  3. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/package.json +1 -1
  4. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/src/browser.ts +18 -3
  5. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/src/cli.ts +14 -0
  6. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/src/server.ts +26 -12
  7. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/tests/browser.test.ts +24 -0
  8. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/tests/server.test.ts +43 -0
  9. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/pyproject.toml +1 -1
  10. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/src/camoufox_cli/cli.py +15 -0
  11. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/.claude-plugin/marketplace.json +0 -0
  12. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/.gitignore +0 -0
  13. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/LICENSE +0 -0
  14. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/Makefile +0 -0
  15. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/README.md +0 -0
  16. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/.gitignore +0 -0
  17. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/LICENSE +0 -0
  18. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/package-lock.json +0 -0
  19. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/package.json +0 -0
  20. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/pnpm-lock.yaml +0 -0
  21. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/scripts/postinstall.js +0 -0
  22. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/src/browser.ts +0 -0
  23. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/src/cli.ts +0 -0
  24. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/src/commands.ts +0 -0
  25. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/src/config.ts +0 -0
  26. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/src/daemon.ts +0 -0
  27. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/src/identity.ts +0 -0
  28. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/src/install.ts +0 -0
  29. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/src/protocol.ts +0 -0
  30. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/src/proxy.ts +0 -0
  31. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/src/refs.ts +0 -0
  32. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/src/server.ts +0 -0
  33. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/tests/browser.test.ts +0 -0
  34. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/tests/cli.test.ts +0 -0
  35. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/tests/commands.test.ts +0 -0
  36. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/tests/e2e.test.ts +0 -0
  37. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/tests/install.test.ts +0 -0
  38. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/tests/protocol.test.ts +0 -0
  39. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/tests/proxy.test.ts +0 -0
  40. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/tests/refs.test.ts +0 -0
  41. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/tests/server.test.ts +0 -0
  42. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/tsconfig.json +0 -0
  43. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/js/vitest.config.ts +0 -0
  44. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/pyproject.toml +0 -0
  45. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/skills/camoufox-cli/SKILL.md +0 -0
  46. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/skills/camoufox-cli/references/commands.md +0 -0
  47. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/skills/camoufox-cli/references/snapshot-refs.md +0 -0
  48. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/src/camoufox_cli/__init__.py +0 -0
  49. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/src/camoufox_cli/__main__.py +0 -0
  50. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/src/camoufox_cli/browser.py +0 -0
  51. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/src/camoufox_cli/cli.py +0 -0
  52. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/src/camoufox_cli/commands.py +0 -0
  53. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/src/camoufox_cli/config.py +0 -0
  54. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/src/camoufox_cli/identity.py +0 -0
  55. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/src/camoufox_cli/install.py +0 -0
  56. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/src/camoufox_cli/protocol.py +0 -0
  57. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/src/camoufox_cli/proxy.py +0 -0
  58. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/src/camoufox_cli/refs.py +0 -0
  59. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/src/camoufox_cli/server.py +0 -0
  60. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/tests/__init__.py +0 -0
  61. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/tests/fixture.html +0 -0
  62. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/tests/test_cli.py +0 -0
  63. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/tests/test_commands.py +0 -0
  64. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/tests/test_e2e.py +0 -0
  65. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/tests/test_install.py +0 -0
  66. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/tests/test_protocol.py +0 -0
  67. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/tests/test_proxy.py +0 -0
  68. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/tests/test_refs.py +0 -0
  69. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude/worktrees/cli-version-command-8852a1/tests/test_server.py +0 -0
  70. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.claude-plugin/marketplace.json +0 -0
  71. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/.gitignore +0 -0
  72. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/LICENSE +0 -0
  73. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/Makefile +0 -0
  74. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/README.md +0 -0
  75. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/.gitignore +0 -0
  76. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/LICENSE +0 -0
  77. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/pnpm-lock.yaml +0 -0
  78. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/scripts/postinstall.js +0 -0
  79. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/src/commands.ts +0 -0
  80. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/src/config.ts +0 -0
  81. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/src/daemon.ts +0 -0
  82. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/src/identity.ts +0 -0
  83. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/src/install.ts +0 -0
  84. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/src/protocol.ts +0 -0
  85. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/src/proxy.ts +0 -0
  86. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/src/refs.ts +0 -0
  87. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/tests/cli.test.ts +0 -0
  88. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/tests/commands.test.ts +0 -0
  89. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/tests/e2e.test.ts +0 -0
  90. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/tests/install.test.ts +0 -0
  91. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/tests/protocol.test.ts +0 -0
  92. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/tests/proxy.test.ts +0 -0
  93. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/tests/refs.test.ts +0 -0
  94. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/tsconfig.json +0 -0
  95. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/js/vitest.config.ts +0 -0
  96. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/skills/camoufox-cli/SKILL.md +0 -0
  97. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/skills/camoufox-cli/references/commands.md +0 -0
  98. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/skills/camoufox-cli/references/snapshot-refs.md +0 -0
  99. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/src/camoufox_cli/__init__.py +0 -0
  100. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/src/camoufox_cli/__main__.py +0 -0
  101. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/src/camoufox_cli/browser.py +0 -0
  102. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/src/camoufox_cli/commands.py +0 -0
  103. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/src/camoufox_cli/config.py +0 -0
  104. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/src/camoufox_cli/identity.py +0 -0
  105. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/src/camoufox_cli/install.py +0 -0
  106. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/src/camoufox_cli/protocol.py +0 -0
  107. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/src/camoufox_cli/proxy.py +0 -0
  108. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/src/camoufox_cli/refs.py +0 -0
  109. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/src/camoufox_cli/server.py +0 -0
  110. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/tests/__init__.py +0 -0
  111. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/tests/fixture.html +0 -0
  112. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/tests/test_cli.py +0 -0
  113. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/tests/test_commands.py +0 -0
  114. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/tests/test_e2e.py +0 -0
  115. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/tests/test_install.py +0 -0
  116. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/tests/test_protocol.py +0 -0
  117. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/tests/test_proxy.py +0 -0
  118. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/tests/test_refs.py +0 -0
  119. {camoufox_cli-0.7.1 → camoufox_cli-0.7.2}/tests/test_server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: camoufox-cli
3
- Version: 0.7.1
3
+ Version: 0.7.2
4
4
  Summary: Anti-detect browser CLI & Skills for AI agents, powered by Camoufox
5
5
  Project-URL: Homepage, https://github.com/Bin-Huang/camoufox-cli
6
6
  Project-URL: Repository, https://github.com/Bin-Huang/camoufox-cli
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "camoufox-cli",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "camoufox-cli",
9
- "version": "0.7.1",
9
+ "version": "0.7.2",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "camoufox-cli",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "Anti-detect headless browser automation CLI & Skills for AI agents, powered by Camoufox",
5
5
  "type": "module",
6
6
  "bin": {
@@ -70,6 +70,8 @@ export class BrowserManager {
70
70
  private tabs = new Map<string, TabState>();
71
71
  private launching: Promise<void> | null = null;
72
72
  private recovering: Promise<void> | null = null;
73
+ // Serializes releaseTab calls; see releaseTab for why.
74
+ private releasing: Promise<void> = Promise.resolve();
73
75
  private persistent: string | null;
74
76
  private proxy: string | null;
75
77
  private geoip: boolean;
@@ -266,10 +268,23 @@ export class BrowserManager {
266
268
  * which would hijack another agent's tab), and when no live tab remains
267
269
  * the browser itself is closed. Idempotent: releasing a tab that has no
268
270
  * open page is a no-op, not an error.
271
+ *
272
+ * Releases are SERIALIZED through a promise chain. Without it, N
273
+ * concurrent closes interleave at the awaits: every one deletes its own
274
+ * map entry first, then every survivor scan sees an emptied map, so all
275
+ * N conclude they are the last tab out — the browser gets torn down N
276
+ * times and isRunning flips false for every in-flight close, which made
277
+ * the server destroy sibling connections before their responses flushed
278
+ * (their tabs were released, but the clients never got the receipt).
269
279
  */
270
- async releaseTab(tab: string): Promise<void> {
271
- // Remove the entry before any await so a concurrent release of another
272
- // tab doesn't still count this one as live.
280
+ releaseTab(tab: string): Promise<void> {
281
+ const run = this.releasing.then(() => this.doReleaseTab(tab));
282
+ // Keep the chain usable even if a release fails.
283
+ this.releasing = run.catch(() => {});
284
+ return run;
285
+ }
286
+
287
+ private async doReleaseTab(tab: string): Promise<void> {
273
288
  const st = this.tabs.get(tab);
274
289
  this.tabs.delete(tab);
275
290
  if (st?.page && !st.page.isClosed()) {
@@ -493,10 +493,24 @@ async function main() {
493
493
  return;
494
494
  } catch (e: any) {
495
495
  lastErr = e.message || String(e);
496
+ // close is idempotent: if the daemon is gone (socket removed), there is
497
+ // nothing left to close — that IS success. This is the receipt for a
498
+ // close that raced the last-tab shutdown: the daemon may exit after
499
+ // releasing our tab but before our response could be delivered.
500
+ if (action === "close" && !fs.existsSync(sockPath)) {
501
+ if (flags.json) console.log(JSON.stringify({ id: "r1", success: true, data: { closed: true } }));
502
+ return;
503
+ }
496
504
  if (attempt < 4) await new Promise((r) => setTimeout(r, 200 * (attempt + 1)));
497
505
  }
498
506
  }
499
507
 
508
+ // Same idempotent-close check once more: the daemon may have finished
509
+ // unlinking its socket only while we were burning the retry budget.
510
+ if (action === "close" && !fs.existsSync(sockPath)) {
511
+ if (flags.json) console.log(JSON.stringify({ id: "r1", success: true, data: { closed: true } }));
512
+ return;
513
+ }
500
514
  process.stderr.write(`Error: Failed to connect to daemon after 5 attempts: ${lastErr}\n`);
501
515
  process.exit(1);
502
516
  }
@@ -21,6 +21,9 @@ export class DaemonServer {
21
21
  // the server side lingering until we destroy it. Track live connections so
22
22
  // shutdown can drop them — otherwise server.close() never emits 'close'.
23
23
  private connections = new Set<net.Socket>();
24
+ // Connections currently processing a request; closeServer defers their
25
+ // destruction until the response has flushed.
26
+ private busy = new Set<net.Socket>();
24
27
 
25
28
  constructor(opts: { session?: string; headless?: boolean; timeout?: number; persistent?: string | null; proxy?: string | null; geoip?: boolean; locale?: string | null }) {
26
29
  this.session = opts.session ?? "default";
@@ -67,7 +70,7 @@ export class DaemonServer {
67
70
 
68
71
  private handleConnection(conn: net.Socket): void {
69
72
  this.connections.add(conn);
70
- conn.on("close", () => this.connections.delete(conn));
73
+ conn.on("close", () => { this.connections.delete(conn); this.busy.delete(conn); });
71
74
 
72
75
  let data = "";
73
76
  let handled = false;
@@ -77,6 +80,10 @@ export class DaemonServer {
77
80
  const nlIdx = data.indexOf("\n");
78
81
  if (nlIdx < 0) return;
79
82
  handled = true;
83
+ // Mark mid-request: closeServer must let this connection's response
84
+ // flush instead of destroying it (its client would otherwise never
85
+ // learn that its command actually succeeded).
86
+ this.busy.add(conn);
80
87
 
81
88
  this.lastActivity = Date.now();
82
89
  const line = data.slice(0, nlIdx).trim();
@@ -97,10 +104,7 @@ export class DaemonServer {
97
104
  // tabs keep the daemon alive, so a non-final close just responds and
98
105
  // its connection cleans up on the normal path.
99
106
  if (command.action === "close" && !this.manager.isRunning) {
100
- // Destroy the current connection only after its response has
101
- // flushed, then drop the rest via closeServer().
102
- conn.once("finish", () => conn.destroy());
103
- this.closeServer(conn);
107
+ this.closeServer();
104
108
  }
105
109
  } catch (e: any) {
106
110
  conn.end(Buffer.from(JSON.stringify({ id: "?", success: false, error: String(e) }) + "\n"));
@@ -169,16 +173,26 @@ export class DaemonServer {
169
173
  }
170
174
 
171
175
  /**
172
- * Stop the server and drop every lingering half-open connection so it can
173
- * actually emit 'close'. Without destroying connections, allowHalfOpen keeps
174
- * them alive and server.close() never completes, so the daemon hangs forever.
175
- * Pass `except` to spare one connection (e.g. the in-flight close command,
176
- * which destroys itself once its response has flushed).
176
+ * Stop the server and drop lingering connections so it can actually emit
177
+ * 'close'. Without destroying idle half-open connections, allowHalfOpen
178
+ * keeps them alive and server.close() never completes, so the daemon hangs
179
+ * forever. Connections that are mid-request (concurrent commands — e.g.
180
+ * several agents' closes racing the last-tab shutdown) are NOT cut: their
181
+ * work has already executed, so destroying them would eat the response and
182
+ * make the client report a failure for a command that succeeded. They are
183
+ * destroyed once their response flushes ('finish'), with a timeout backstop
184
+ * so a wedged handler can't keep the daemon alive forever.
177
185
  */
178
- private closeServer(except?: net.Socket): void {
186
+ private closeServer(): void {
179
187
  this.server?.close();
180
188
  for (const c of this.connections) {
181
- if (c !== except) c.destroy();
189
+ if (!this.busy.has(c) || c.writableFinished) {
190
+ c.destroy();
191
+ continue;
192
+ }
193
+ const backstop = setTimeout(() => c.destroy(), 10_000);
194
+ backstop.unref();
195
+ c.once("finish", () => { clearTimeout(backstop); c.destroy(); });
182
196
  }
183
197
  }
184
198
 
@@ -29,6 +29,30 @@ describe("BrowserManager", () => {
29
29
  expect(launches).toBe(2);
30
30
  });
31
31
 
32
+ it("concurrent releases: only the true last tab out closes the browser", async () => {
33
+ const manager = new BrowserManager();
34
+ let closes = 0;
35
+ vi.spyOn(manager, "close").mockImplementation(async () => { closes++; });
36
+ // Fake pages that take a moment to close, forcing the releases to
37
+ // interleave at the await — the exact shape of the production race.
38
+ const mkTab = () => ({
39
+ page: { isClosed: () => false, close: () => new Promise((r) => setTimeout(r, 20)) },
40
+ });
41
+ (manager as any).tabs.set("a", mkTab());
42
+ (manager as any).tabs.set("b", mkTab());
43
+ (manager as any).tabs.set("c", mkTab());
44
+
45
+ await Promise.all([
46
+ manager.releaseTab("a"),
47
+ manager.releaseTab("b"),
48
+ manager.releaseTab("c"),
49
+ ]);
50
+ // Unserialized, every release sees an emptied map and all three "close
51
+ // the browser"; serialized, only the genuine last one does.
52
+ expect(closes).toBe(1);
53
+ expect((manager as any).tabs.size).toBe(0);
54
+ });
55
+
32
56
  it("getPage rejects when not launched", async () => {
33
57
  const manager = new BrowserManager();
34
58
  await expect(manager.getPage()).rejects.toThrow("not launched");
@@ -178,6 +178,49 @@ describe("DaemonServer", () => {
178
178
  await serverPromise;
179
179
  });
180
180
 
181
+ it("concurrent closes all receive a response (none eaten by shutdown)", async () => {
182
+ // Regression: N agents closing at once. The first close to complete used
183
+ // to see isRunning=false and destroy every sibling connection before its
184
+ // response flushed — the tabs were released but the clients reported
185
+ // failure. All N clients must get a parseable success response.
186
+ const server = new DaemonServer({ session: TEST_SESSION, timeout: 5 });
187
+ const serverPromise = server.start();
188
+ for (let i = 0; i < 50; i++) {
189
+ if (fs.existsSync(SOCK_PATH)) break;
190
+ await new Promise(r => setTimeout(r, 100));
191
+ }
192
+
193
+ const sendClose = (id: string) => new Promise<string>((resolve, reject) => {
194
+ const client = net.createConnection(SOCK_PATH, () => {
195
+ client.end(JSON.stringify({ id, action: "close", params: {} }) + "\n");
196
+ });
197
+ let data = "";
198
+ client.on("data", chunk => { data += chunk.toString(); });
199
+ client.on("end", () => resolve(data));
200
+ client.on("error", reject);
201
+ });
202
+
203
+ const responses = await Promise.all(
204
+ ["c1", "c2", "c3", "c4", "c5", "c6"].map(sendClose)
205
+ );
206
+ // Every connection whose request began processing must get a full,
207
+ // parseable response (the old code destroyed them mid-response). A
208
+ // sibling that had not even been read yet may be dropped at shutdown —
209
+ // the real CLI covers that via its idempotent-close fallback — but a
210
+ // partial/truncated response is never acceptable.
211
+ let delivered = 0;
212
+ for (const raw of responses) {
213
+ if (raw === "") continue;
214
+ const parsed = JSON.parse(raw); // truncated raw -> test fails here
215
+ expect(parsed.success).toBe(true);
216
+ delivered++;
217
+ }
218
+ expect(delivered).toBeGreaterThan(0);
219
+
220
+ await serverPromise; // daemon still shuts down cleanly afterwards
221
+ expect(fs.existsSync(SOCK_PATH)).toBe(false);
222
+ });
223
+
181
224
  it("cleans up socket and pid on shutdown", async () => {
182
225
  const server = new DaemonServer({
183
226
  session: TEST_SESSION,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "camoufox-cli"
3
- version = "0.7.1"
3
+ version = "0.7.2"
4
4
  description = "Anti-detect browser CLI & Skills for AI agents, powered by Camoufox"
5
5
  requires-python = ">=3.10"
6
6
  license = "MIT"
@@ -502,9 +502,24 @@ def main():
502
502
  return
503
503
  except Exception as e:
504
504
  last_err = str(e)
505
+ # close is idempotent: if the daemon is gone (socket removed),
506
+ # there is nothing left to close — that IS success. This is the
507
+ # receipt for a close that raced the last-tab shutdown: the daemon
508
+ # may exit after releasing our tab but before responding.
509
+ if action == "close" and not os.path.exists(sock_path):
510
+ if flags["json"]:
511
+ print(json.dumps({"id": "r1", "success": True, "data": {"closed": True}}))
512
+ return
505
513
  if attempt < 4:
506
514
  time.sleep(0.2 * (attempt + 1))
507
515
 
516
+ # Same idempotent-close check once more: the daemon may have finished
517
+ # unlinking its socket only while we were burning the retry budget.
518
+ if action == "close" and not os.path.exists(sock_path):
519
+ if flags["json"]:
520
+ print(json.dumps({"id": "r1", "success": True, "data": {"closed": True}}))
521
+ return
522
+
508
523
  print(f"Error: Failed to connect to daemon after 5 attempts: {last_err}", file=sys.stderr)
509
524
  sys.exit(1)
510
525
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes