gowalk-cicd 1.0.120 → 1.0.122

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/CLAUDE.md CHANGED
@@ -145,6 +145,9 @@ node /path/to/gowalk-cicd/bin/cli.mjs
145
145
  The wrapper disables Gradle daemons and closes its relay when the command ends.
146
146
  The relay's idle deadline covers the whole tunnel: traffic in either direction
147
147
  keeps downloads/uploads alive. An idle direction alone must never end a transfer.
148
+ A clean directional EOF forwards a half-close when the transport supports it and
149
+ leaves the opposite stream alive until completion, error or the shared idle deadline.
150
+ Never cancel an unfinished reply or upload merely because the other pump completed.
148
151
  - Flutter bootstrap is part of that boundary: before the Flutter setup action,
149
152
  `play_store_proxy.py --bootstrap-env` validates the Google account pin and writes
150
153
  both proxy/bypass spellings to the private job environment without console output.
@@ -1 +1 @@
1
- 1.0.120
1
+ 1.0.122
@@ -1 +1 @@
1
- 1.0.120
1
+ 1.0.122
@@ -68,6 +68,19 @@ class Relay:
68
68
  activity[0] = self.loop.time()
69
69
  writer.write(data)
70
70
  await writer.drain()
71
+ if writer.can_write_eof():
72
+ writer.write_eof()
73
+ await writer.drain()
74
+
75
+ async def _wait_for_streams(self, pumps):
76
+ pending, idle = set(pumps[:2]), pumps[2]
77
+ while pending:
78
+ done, _ = await asyncio.wait([*pending, idle], return_when=asyncio.FIRST_COMPLETED)
79
+ if idle in done:
80
+ return
81
+ for task in done:
82
+ task.result()
83
+ pending.difference_update(done)
71
84
 
72
85
  async def _idle(self, activity):
73
86
  while remaining := max(0.0, activity[0] + IDLE_TIMEOUT - self.loop.time()):
@@ -91,7 +104,9 @@ class Relay:
91
104
  pumps = [asyncio.create_task(self._pump(reader, upstream, activity)),
92
105
  asyncio.create_task(self._pump(upstream_reader, writer, activity)),
93
106
  asyncio.create_task(self._idle(activity))]
94
- await asyncio.wait(pumps, return_when=asyncio.FIRST_COMPLETED)
107
+ # A clean directional EOF still permits the other side to finish.
108
+ # Errors or the shared idle deadline continue to close the tunnel.
109
+ await self._wait_for_streams(pumps)
95
110
  except (OSError, ValueError, IndexError, asyncio.TimeoutError,
96
111
  asyncio.IncompleteReadError, asyncio.LimitOverrunError):
97
112
  # Once bytes flow, the stream is the client's protocol (usually TLS): a
@@ -102,15 +117,18 @@ class Relay:
102
117
  finally:
103
118
  for task in pumps:
104
119
  task.cancel()
120
+ # Cleanup itself can be cancelled while awaiting a pump or writer.
121
+ # Close both transports before the first cancellable cleanup await.
122
+ connections = [connection for connection in (writer, upstream) if connection]
123
+ for connection in connections:
124
+ connection.close()
105
125
  if pumps:
106
126
  await asyncio.gather(*pumps, return_exceptions=True)
107
- for connection in (writer, upstream):
108
- if connection:
109
- connection.close()
110
- try:
111
- await connection.wait_closed()
112
- except OSError:
113
- pass
127
+ for connection in connections:
128
+ try:
129
+ await connection.wait_closed()
130
+ except OSError:
131
+ pass
114
132
 
115
133
 
116
134
  @contextmanager
@@ -88,7 +88,16 @@ def call(phase: str, operation, *, retry: bool = False, edit_id: str | None = No
88
88
  else:
89
89
  if not retry or getattr(result, "status_code", None) not in RETRY_STATUSES:
90
90
  if accepted is not None and result.status_code not in accepted:
91
- fail(phase, "provider_refused", attempt, status=result.status_code, edit_id=edit_id)
91
+ # A transient upstream status is not a refusal, even where the
92
+ # phase may not replay it: `edit_create` never repeats an
93
+ # ambiguous insert, so a 503 there ends the build — and calling
94
+ # that "provider_refused" sent the next session hunting for a
95
+ # configuration Google never objected to (Foundy, 2026-09-09,
96
+ # run 34345638488). The remedy for one is to dispatch again;
97
+ # for the other it is to change something first.
98
+ code = ("provider_unavailable" if result.status_code in RETRY_STATUSES
99
+ else "provider_refused")
100
+ fail(phase, code, attempt, status=result.status_code, edit_id=edit_id)
92
101
  return result
93
102
  if attempt == limit:
94
103
  fail(phase, "provider_unavailable", attempt, status=result.status_code, edit_id=edit_id)
@@ -63,6 +63,26 @@ class PreflightTransportTests(unittest.TestCase):
63
63
  self.assertIn('"edit_id": "123"', str(caught.exception))
64
64
  self.assertEqual(operation.call_count, 3)
65
65
 
66
+ def test_a_transient_status_is_unavailable_even_where_the_phase_cannot_replay(self):
67
+ """`edit_create` must not repeat an ambiguous insert, but naming its 503 a
68
+ refusal told the next session to go and fix a configuration Google never
69
+ objected to. The status decides the word; the retry policy is unchanged."""
70
+ operation = mock.Mock(return_value=mock.Mock(status_code=503))
71
+ with self.assertRaises(SystemExit) as caught:
72
+ transport.call("edit_create", operation, accepted=(200, 404), retry_connect=True)
73
+ self.assertIn('"code": "provider_unavailable"', str(caught.exception))
74
+ self.assertIn('"status": 503', str(caught.exception))
75
+ self.assertIn('"attempts": 1', str(caught.exception))
76
+ operation.assert_called_once()
77
+ self.sleep.assert_not_called()
78
+
79
+ def test_a_real_refusal_keeps_its_own_word(self):
80
+ operation = mock.Mock(return_value=mock.Mock(status_code=403))
81
+ with self.assertRaises(SystemExit) as caught:
82
+ transport.call("edit_create", operation, accepted=(200, 404), retry_connect=True)
83
+ self.assertIn('"code": "provider_refused"', str(caught.exception))
84
+ self.assertIn('"status": 403', str(caught.exception))
85
+
66
86
  def test_refusal_after_retry_records_the_actual_attempt_in_a_safe_annotation(self):
67
87
  operation = mock.Mock(side_effect=[mock.Mock(status_code=503), mock.Mock(status_code=403)])
68
88
  with self.assertRaises(SystemExit) as caught:
@@ -0,0 +1,148 @@
1
+ """Owned socket regressions for complete replies and uploads after directional EOF."""
2
+ import asyncio
3
+ from contextlib import contextmanager
4
+ import socket
5
+ import threading
6
+ import time
7
+ import unittest
8
+ from unittest import mock
9
+
10
+ import jvm_proxy_relay as relay
11
+
12
+
13
+ def read_all(connection):
14
+ chunks = []
15
+ while chunk := connection.recv(65536):
16
+ chunks.append(chunk)
17
+ return b"".join(chunks)
18
+
19
+
20
+ @contextmanager
21
+ def endpoint(mode, payload):
22
+ listener = socket.socket()
23
+ listener.bind(("127.0.0.1", 0))
24
+ listener.listen(1)
25
+ listener.settimeout(3)
26
+ received, failures, finished = [], [], threading.Event()
27
+
28
+ def serve():
29
+ try:
30
+ with listener.accept()[0] as connection:
31
+ connection.settimeout(3)
32
+ header = b""
33
+ while not header.endswith(b"\r\n\r\n"):
34
+ chunk = connection.recv(1)
35
+ if not chunk:
36
+ raise ConnectionError("incomplete CONNECT header")
37
+ header += chunk
38
+ connection.sendall(b"HTTP/1.1 200 Connection established\r\n\r\n")
39
+ if mode == "download":
40
+ received.append(read_all(connection))
41
+ connection.sendall(payload)
42
+ connection.shutdown(socket.SHUT_WR)
43
+ if mode == "upload":
44
+ received.append(read_all(connection))
45
+ except OSError as exc:
46
+ failures.append(type(exc).__name__)
47
+ finally:
48
+ listener.close()
49
+ finished.set()
50
+
51
+ thread = threading.Thread(target=serve)
52
+ thread.start()
53
+ try:
54
+ yield f"http://127.0.0.1:{listener.getsockname()[1]}", received, finished
55
+ finally:
56
+ thread.join(timeout=5)
57
+ listener.close()
58
+ assert not thread.is_alive(), "owned upstream did not finish"
59
+ assert not failures, failures
60
+
61
+
62
+ @contextmanager
63
+ def connect(port):
64
+ with socket.create_connection(("127.0.0.1", port), timeout=3) as connection:
65
+ connection.sendall(b"CONNECT unresolvable.invalid:443 HTTP/1.1\r\nHost: ignored\r\n\r\n")
66
+ header = b""
67
+ while not header.endswith(b"\r\n\r\n"):
68
+ chunk = connection.recv(1)
69
+ if not chunk:
70
+ raise ConnectionError("incomplete CONNECT response")
71
+ header += chunk
72
+ assert header.startswith(b"HTTP/1.1 200 "), header
73
+ yield connection
74
+
75
+
76
+ class HalfCloseTests(unittest.TestCase):
77
+ def test_client_half_close_preserves_complete_delayed_reply(self):
78
+ for size in (81920, 4 * 1024 * 1024):
79
+ payload = bytes(range(256)) * (size // 256)
80
+ with self.subTest(size=size), endpoint("download", payload) as (proxy, received, finished):
81
+ with relay.running(proxy) as port, connect(port) as client:
82
+ client.sendall(b"complete request")
83
+ client.shutdown(socket.SHUT_WR)
84
+ time.sleep(0.05) # Let the larger response encounter socket backpressure.
85
+ self.assertEqual(read_all(client), payload)
86
+ self.assertTrue(finished.wait(2))
87
+ self.assertEqual(received, [b"complete request"])
88
+
89
+ def test_upstream_half_close_preserves_remaining_upload(self):
90
+ payload = bytes(range(256)) * 4096
91
+ with endpoint("upload", b"response finished") as (proxy, received, finished):
92
+ with relay.running(proxy) as port, connect(port) as client:
93
+ self.assertEqual(read_all(client), b"response finished")
94
+ client.sendall(payload)
95
+ client.shutdown(socket.SHUT_WR)
96
+ self.assertTrue(finished.wait(2))
97
+ self.assertEqual(received, [payload])
98
+
99
+ def test_idle_half_closed_tunnel_still_expires_and_cleans_up(self):
100
+ with endpoint("upload", b"finished") as (proxy, received, finished):
101
+ with mock.patch.object(relay, "IDLE_TIMEOUT", 0.1), relay.running(proxy) as port:
102
+ with connect(port) as client:
103
+ self.assertEqual(read_all(client), b"finished")
104
+ self.assertTrue(finished.wait(2))
105
+ self.assertEqual(received, [b""])
106
+ with self.assertRaises(OSError):
107
+ socket.create_connection(("127.0.0.1", port), timeout=0.1)
108
+
109
+
110
+ class SecureTransportTests(unittest.IsolatedAsyncioTestCase):
111
+ async def test_transport_without_half_close_support_is_not_closed_by_upload_eof(self):
112
+ instance = relay.Relay.__new__(relay.Relay)
113
+ instance.loop = asyncio.get_running_loop()
114
+ reader = mock.Mock(read=mock.AsyncMock(side_effect=[b"complete request", b""]))
115
+ writer = mock.Mock(drain=mock.AsyncMock())
116
+ writer.can_write_eof.return_value = False
117
+ await instance._pump(reader, writer, [instance.loop.time()])
118
+ writer.write.assert_called_once_with(b"complete request")
119
+ writer.write_eof.assert_not_called()
120
+ writer.close.assert_not_called()
121
+
122
+ async def test_cancellation_during_cleanup_closes_both_connections(self):
123
+ gather = asyncio.gather
124
+ for phase in ("pumps", "writer"):
125
+ with self.subTest(phase=phase):
126
+ closing = asyncio.Event()
127
+
128
+ async def wait_for_close(*_args, **_kwargs):
129
+ closing.set()
130
+ await asyncio.Future()
131
+
132
+ instance = relay.Relay.__new__(relay.Relay)
133
+ instance.loop = asyncio.get_running_loop()
134
+ reader = mock.Mock(readuntil=mock.AsyncMock(return_value=b"CONNECT ignored:443 HTTP/1.1\r\n\r\n"))
135
+ upstream_reader = mock.Mock(readuntil=mock.AsyncMock(return_value=b"HTTP/1.1 200 OK\r\n\r\n"))
136
+ writer = mock.Mock(drain=mock.AsyncMock(), wait_closed=mock.AsyncMock(side_effect=wait_for_close))
137
+ upstream_writer = mock.Mock(wait_closed=mock.AsyncMock())
138
+ instance._upstream = mock.AsyncMock(return_value=(upstream_reader, upstream_writer))
139
+ instance._wait_for_streams = mock.AsyncMock()
140
+ cleanup = wait_for_close if phase == "pumps" else gather
141
+ with mock.patch.object(relay.asyncio, "gather", side_effect=cleanup):
142
+ handler = asyncio.create_task(instance._handle(reader, writer))
143
+ await asyncio.wait_for(closing.wait(), timeout=2)
144
+ handler.cancel()
145
+ with self.assertRaises(asyncio.CancelledError):
146
+ await handler
147
+ writer.close.assert_called_once()
148
+ upstream_writer.close.assert_called_once()
@@ -1 +1 @@
1
- 1.0.120
1
+ 1.0.122
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.120",
3
+ "version": "1.0.122",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {