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 +3 -0
- package/action/.daemux-version +1 -1
- package/action/scripts/mobile_release.cjs +12 -1
- package/android-action/.daemux-version +1 -1
- package/android-action/scripts/jvm_proxy_relay.py +26 -8
- package/android-action/scripts/test_relay_half_close.py +148 -0
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
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.
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
connection.
|
|
110
|
-
|
|
111
|
-
|
|
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.
|
|
1
|
+
1.0.121
|