Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,9 @@ jobs:
- name: Start local PostgreSQL
run: |
make local-postgres-start
echo "ADAPTER_TEST_URL=$(cat .ignore/r2/tmp/local-postgres/postgres.url)" >> "$GITHUB_ENV"
adapter_test_url="$(cat .ignore/r2/tmp/local-postgres/postgres.url)"
echo "::add-mask::$adapter_test_url"
echo "ADAPTER_TEST_URL=$adapter_test_url" >> "$GITHUB_ENV"

- name: Enable KVM permissions
run: |
Expand Down Expand Up @@ -457,7 +459,9 @@ jobs:
- name: Start local PostgreSQL
run: |
make local-postgres-start
echo "ADAPTER_TEST_URL=$(cat .ignore/r2/tmp/local-postgres/postgres.url)" >> "$GITHUB_ENV"
adapter_test_url="$(cat .ignore/r2/tmp/local-postgres/postgres.url)"
echo "::add-mask::$adapter_test_url"
echo "ADAPTER_TEST_URL=$adapter_test_url" >> "$GITHUB_ENV"

- name: Enable KVM permissions
run: |
Expand Down
61 changes: 52 additions & 9 deletions clients/swift/Tests/SynchroTests/IntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,15 +143,37 @@ final class IntegrationTests: XCTestCase {
}

private func syncAndWaitForScheduledRetry(_ client: SynchroClient) async throws {
do {
try await client.syncNow()
} catch let error as RetryableError where error.classification == .http503 {
// A real server answers with retryable 503 capture_pending until WAL
// capture reaches accepted writes. The error names no protocol code,
// and a retryable 503 admits only capture_pending and
// temporary_unavailable, so every other failure propagates.
try await waitForCondition(timeoutNanoseconds: 15_000_000_000) {
client.getSyncStatus() == .ready
let deadline = DispatchTime.now().uptimeNanoseconds + 15_000_000_000
while true {
var retryCaller = false
do {
try await client.syncNow()
return
} catch let error as RetryableError where error.classification == .http503 {
// The engine must complete its persisted retry request before
// another manual sync can start.
} catch SynchroError.notStarted {
guard client.getSyncStatus() == .backoff else {
throw SynchroError.notStarted
}
retryCaller = true
}
let now = DispatchTime.now().uptimeNanoseconds
guard now < deadline else {
XCTFail("timed out waiting for scheduled sync retry")
throw SynchroError.notStarted
}
var reachedReady = false
try await waitForCondition(timeoutNanoseconds: deadline - now) {
let ready = client.getSyncStatus() == .ready
reachedReady = ready
return ready
}
guard reachedReady else {
throw SynchroError.notStarted
}
if !retryCaller {
return
}
}
}
Expand Down Expand Up @@ -192,6 +214,27 @@ final class IntegrationTests: XCTestCase {
}
}

func testScheduledRetryHelperPropagatesStoppedClient() async throws {
let client = try SynchroClient(config: makeConfig(
userID: UUID().uuidString.lowercased(),
dbPath: tempDBPath()
))
addTeardownBlock { await self.stopAndClose(client) }

try await client.start()
await client.stop()
XCTAssertEqual(client.getSyncStatus(), .stopped)

do {
try await syncAndWaitForScheduledRetry(client)
XCTFail("Expected stopped client to reject syncNow")
} catch SynchroError.notStarted {
} catch {
XCTFail("Expected notStarted, got \(error)")
}
XCTAssertEqual(client.getSyncStatus(), .stopped)
}

func testPushPullBetweenTwoClients() async throws {
let userID = UUID().uuidString.lowercased()
let clientAConfig = makeConfig(userID: userID, dbPath: tempDBPath(), syncInterval: 0.1)
Expand Down
9 changes: 6 additions & 3 deletions scripts/ci/run_android_emulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ def cancel(self, number: int, _frame) -> None:
if not self.cleaning:
raise Cancelled()

def command(self, command: list[str], *, timeout: float = 180, input: str | None = None) -> str:
def command(self, command: list[str], *, timeout: float = 180, input: str | None = None, public_failure_output: bool = False) -> str:
cleaning = self.cleaning
self.command_process = subprocess.Popen(
command, env=self.environment, start_new_session=True,
Expand Down Expand Up @@ -266,7 +266,10 @@ def command(self, command: list[str], *, timeout: float = 180, input: str | None
self.check_adb_server()
status = self.command_process.returncode
if status:
raise RunnerError(f"Command failed with status {status}: {shlex.join(command)}")
diagnostic = ""
if public_failure_output:
diagnostic = "\n" + output.encode("utf-8")[-4096:].decode("utf-8", errors="ignore").rstrip()
raise RunnerError(f"Command failed with status {status}: {shlex.join(command)}{diagnostic}")
except BaseException as error:
failure = error
raise
Expand Down Expand Up @@ -377,7 +380,7 @@ def prepare(self) -> None:
if not cached:
cache.parent.mkdir(parents=True, exist_ok=True)
archive.replace(cache)
version = self.command([str(directory / "emulator"), "-version"])
version = self.command([str(directory / "emulator"), "-no-window", "-version"], public_failure_output=True)
if not re.search(r"\bAndroid emulator version 37\.2\.12\b.*\(build_id 16428233\)", version):
raise RunnerError("The emulator executable version or build does not match the pin")
identity = f"Emulator SHA-256 {ARCHIVE_SHA256}, version {VERSION}, build {BUILD}"
Expand Down
28 changes: 28 additions & 0 deletions scripts/ci/test_run_android_emulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,9 @@ def test_complete_selected_command_and_environment(self):
"--connect-timeout", "30", "--max-time", "180", "--output",
])
self.assertEqual(curl[-1], runner.ARCHIVE_URL)
self.assertEqual(self.commands("emulator")[0][0], [
str(self.sdk / "emulator/emulator"), "-no-window", "-version",
])
self.assertEqual(self.commands("sdkmanager")[0][0], [
str(self.manager), f"--sdk_root={self.sdk}", "platforms;android-34",
"system-images;android-34;google_apis;x86_64",
Expand Down Expand Up @@ -823,6 +826,31 @@ def test_command_cleanup_failure_keeps_original_failure_visible(self):
self.assertIn("status 9", errors.getvalue())
self.assertIn("cleanup failed", errors.getvalue())

def test_identity_failure_diagnostic_retains_only_a_bounded_utf8_tail(self):
output = "private-prefix\n" + "é" * 5000 + "\nloader-error-marker\n"
with self.boundaries():
execution = runner.Runner(self.selected())
execution.log = io.StringIO()
with mock.patch.object(runner.subprocess, "Popen", return_value=Process(100, output=output, status=127)):
with self.assertRaises(runner.RunnerError) as failure:
execution.command([str(self.sdk / "emulator/emulator"), "-no-window", "-version"], public_failure_output=True)
diagnostic = str(failure.exception).split("\n", 1)[1]
self.assertLessEqual(len(diagnostic.encode("utf-8")), 4096)
self.assertNotIn("private-prefix", diagnostic)
self.assertTrue(diagnostic.endswith("loader-error-marker"))
self.assertEqual(execution.log.getvalue(), output)

def test_generic_command_failure_keeps_output_private(self):
output = "fixture-private-output\n"
with self.boundaries():
execution = runner.Runner(self.selected())
execution.log = io.StringIO()
with mock.patch.object(runner.subprocess, "Popen", return_value=Process(100, output=output, status=127)):
with self.assertRaises(runner.RunnerError) as failure:
execution.command(["fixture"])
self.assertNotIn("fixture-private-output", str(failure.exception))
self.assertEqual(execution.log.getvalue(), output)

def test_cancellation_and_previous_signal_handlers(self):
previous = {number: signal.getsignal(number) for number in (signal.SIGINT, signal.SIGTERM)}
for number in previous:
Expand Down
Loading