staysfixed 0.9.1 → 0.11.0

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +182 -0
  2. package/README.md +17 -5
  3. package/docs/getting-started.md +10 -0
  4. package/docs/how-v2-works.md +5 -2
  5. package/package.json +2 -2
  6. package/src/guard/api.js +107 -3
  7. package/src/guard/run.js +154 -20
  8. package/src/report/console.js +235 -17
  9. package/src/report/html.js +75 -19
  10. package/src/types.js +5 -0
  11. package/src/v2/adapters/android-driver.js +62 -12
  12. package/src/v2/adapters/contract.js +18 -4
  13. package/src/v2/adapters/electron.js +96 -14
  14. package/src/v2/adapters/http.js +264 -23
  15. package/src/v2/adapters/ios-driver.js +22 -4
  16. package/src/v2/adapters/ios.js +5 -2
  17. package/src/v2/adapters/isolate.js +78 -5
  18. package/src/v2/adapters/process.js +350 -92
  19. package/src/v2/adapters/web-driver.js +23 -1
  20. package/src/v2/adapters/web.js +42 -3
  21. package/src/v2/adapters/windows.js +32 -15
  22. package/src/v2/check.js +526 -19
  23. package/src/v2/cli.js +345 -3
  24. package/src/v2/cluster.js +112 -4
  25. package/src/v2/coverage.js +293 -8
  26. package/src/v2/detect.js +182 -9
  27. package/src/v2/doctor.js +253 -30
  28. package/src/v2/init.js +102 -10
  29. package/src/v2/mcp/server.js +4 -1
  30. package/src/v2/mcp/tools.js +291 -24
  31. package/src/v2/normalise.js +11 -0
  32. package/src/v2/observation.js +57 -5
  33. package/src/v2/reference.js +133 -14
  34. package/src/v2/refusal.js +389 -0
  35. package/src/v2/remote.js +24 -3
  36. package/src/v2/run.js +306 -16
  37. package/src/v2/sealed.js +14 -2
  38. package/src/v2/ship.js +286 -22
  39. package/src/v2/store.js +101 -2
  40. package/src/v2/types.js +5 -0
  41. package/src/v2/waiver.js +9 -2
  42. package/src/watch/panel.js +12 -1
@@ -79,6 +79,9 @@ import {
79
79
  trimForStorage,
80
80
  } from './contract.js';
81
81
  import { RemoteLinkLost, remoteRunner } from '../remote.js';
82
+ // Every wait here has a limit and every limit says what it was waiting for; the pieces are in
83
+ // process.js so there is one of each rather than one per adapter.
84
+ import { endOfChild, letGoOf } from './process.js';
82
85
 
83
86
  /** @typedef {import('./contract.js').Build} Build */
84
87
  /** @typedef {import('./contract.js').PreparedBuild} PreparedBuild */
@@ -814,21 +817,35 @@ export function asList(value) {
814
817
  */
815
818
  export async function pushBuild(host, localDir, remoteDir) {
816
819
  const started = Date.now();
817
- return await new Promise((resolve) => {
818
- const tar = spawn('tar', ['-cf', '-', '-C', path.dirname(localDir), path.basename(localDir)]);
819
- const ssh = spawn('ssh', ['-o', 'BatchMode=yes', host, `mkdir -p '${remoteDir}' && tar -xf - -C '${remoteDir}'`]);
820
- let trouble = '';
821
- tar.stdout.pipe(ssh.stdin);
822
- ssh.stderr.on('data', (d) => { trouble += String(d); });
823
- tar.stderr.on('data', (d) => { trouble += String(d); });
824
- ssh.on('close', (code) => {
825
- const ms = Date.now() - started;
826
- resolve(code === 0
827
- ? { ok: true, ms, why: `Copied to ${host} in ${timeBucket(ms)}.` }
828
- : { ok: false, ms, why: `Copying to ${host} failed: ${trouble.trim().slice(0, 300) || `tar exited ${code}`}` });
829
- });
830
- ssh.on('error', (e) => resolve({ ok: false, ms: Date.now() - started, why: `Could not start the copy: ${e.message}` }));
831
- });
820
+ // `ConnectTimeout` because ssh with no answer at the far end will sit on a half-open socket
821
+ // for as long as the network lets it, and the whole point of this pass is that nothing here
822
+ // is allowed to wait without a clock on it.
823
+ const tar = spawn('tar', ['-cf', '-', '-C', path.dirname(localDir), path.basename(localDir)]);
824
+ const ssh = spawn('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15', host, `mkdir -p '${remoteDir}' && tar -xf - -C '${remoteDir}'`]);
825
+ let trouble = '';
826
+ tar.stdout.pipe(ssh.stdin);
827
+ ssh.stderr.on('data', (d) => { trouble += String(d); });
828
+ tar.stderr.on('data', (d) => { trouble += String(d); });
829
+ // Nothing wants what ssh prints on its way through, and that is exactly why it has to be
830
+ // read. A pipe nobody empties fills up, and a full pipe blocks its writer for ever — the
831
+ // same hang as an unclosed one, arriving from the other direction.
832
+ ssh.stdout?.resume();
833
+ // A pipe with nobody reading it fills up and blocks the writer for ever, so if ssh is gone
834
+ // tar has to be told rather than left leaning on a dead pipe.
835
+ ssh.on('error', () => { try { tar.kill('SIGKILL'); } catch { /* already gone */ } });
836
+ tar.on('error', () => { try { ssh.kill('SIGKILL'); } catch { /* already gone */ } });
837
+
838
+ // Thirty minutes for a whole build over a network, which is far longer than it has ever
839
+ // taken and still a limit. A copy that never finishes and never says so is the shape of the
840
+ // hang this whole pass exists to remove.
841
+ const ended = await endOfChild(ssh, { limitMs: 30 * 60_000, what: `the copy of this build to ${host}` });
842
+ try { tar.kill('SIGKILL'); } catch { /* it finished on its own */ }
843
+ letGoOf(tar);
844
+
845
+ const ms = Date.now() - started;
846
+ if (ended.gaveUp) return { ok: false, ms, why: `${ended.why} ${trouble.trim().slice(0, 200)}`.trim() };
847
+ if (ended.code === 0) return { ok: true, ms, why: `Copied to ${host} in ${timeBucket(ms)}.` };
848
+ return { ok: false, ms, why: `Copying to ${host} failed: ${trouble.trim().slice(0, 300) || `the copy ended with ${ended.code ?? ended.signal}`}` };
832
849
  }
833
850
 
834
851
  // ---------------------------------------------------------------------------