gowalk-cicd 1.0.119 → 1.0.121

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.119
1
+ 1.0.121
@@ -66,8 +66,19 @@ async function phase(action, platform) {
66
66
  if (!id) return; // Existing consumers without release-intent wiring keep their original behavior.
67
67
  const record = await api.request('GET', `/deployments/${id}`);
68
68
  const expected = owner(platform, record.payload?.fingerprint);
69
+ // An EARLIER attempt of this same run owns the record legitimately. `admit`
70
+ // only re-runs when it failed, so "re-run failed jobs" — the ordinary way to
71
+ // recover a release that died in transport — leaves the record stamped with
72
+ // attempt 1 while the platform job runs as attempt 2. Demanding an exact
73
+ // match made that recovery impossible: obd-scanner's Android leg built for
74
+ // twenty-six minutes, then refused at the upload intent (2026-09-09).
75
+ // Upload-once does not rest on this number. It rests on the status check
76
+ // below, which admits `begin-upload` only from `building`; a record whose
77
+ // upload already began or finished is refused there, on any attempt.
78
+ const attempt = record.payload?.run_attempt;
69
79
  if (record.payload?.schema !== SCHEMA || record.payload.run_id !== expected.run_id ||
70
- record.payload.run_attempt !== expected.run_attempt || record.sha !== expected.source_sha ||
80
+ !Number.isSafeInteger(attempt) || attempt > expected.run_attempt ||
81
+ record.sha !== expected.source_sha ||
71
82
  record.payload.platform !== platform) throw Error('mobile_release_phase_owner_mismatch');
72
83
  const statuses = await api.request('GET', `/deployments/${id}/statuses?per_page=1`);
73
84
  const required = action === 'begin-upload' ? 'building' : 'uploading';
@@ -1 +1 @@
1
- 1.0.119
1
+ 1.0.121
@@ -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
@@ -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.119
1
+ 1.0.121
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.119",
3
+ "version": "1.0.121",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {