1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
|
"""
Persistent javac daemon client for ya build.
Drop-in replacement for subprocess javac calls. Connects to JavacDaemon
over a Unix socket; starts the daemon lazily on first use and reuses it
across build nodes in the same build session.
Daemon selection is keyed by the JDK resource path (socket) and by the
SHA-256 of the embedded jar (cache dir). Different JDK versions always
use separate daemon instances. Different users building with the same
daemon version share one warm JVM safely (cache dir is content-addressed).
Usage from build scripts:
import javac_daemon_client as jdc
rc = jdc.compile(javac_bin='/path/to/jdk/bin/javac', args=[...])
# or, when caller wants to process the stderr output itself:
rc, stderr_bytes = jdc.compile_raw(javac_bin, args)
Opt-in via environment variable (off by default):
YA_JAVAC_DAEMON=1 enable daemon mode
YA_JAVAC_DAEMON=0 use subprocess javac (default)
"""
import hashlib
import os
import socket
import struct
import subprocess
import sys
import time
# ---------------------------------------------------------------------------
# JavacDaemon.jar lives next to this client, committed to the repo. It is
# built by the JAVA_PROGRAM module at devtools/buildstep_tools/javac_daemon.
#
# To regenerate after changing JavacDaemon.java:
# ya make devtools/buildstep_tools/javac_daemon
# cp -L devtools/buildstep_tools/javac_daemon/buildstep_tools-javac_daemon.jar \
# build/scripts/JavacDaemon.jar
# arc add build/scripts/JavacDaemon.jar
#
# The cache dir is content-addressed on the jar bytes (see _jar_hash), so a
# freshly committed jar transparently spins up a new warm daemon.
# ---------------------------------------------------------------------------
_DAEMON_JAR_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "JavacDaemon.jar")
def _read_jar_bytes() -> bytes:
with open(_DAEMON_JAR_PATH, "rb") as f:
return f.read()
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
DAEMON_START_TIMEOUT = 10.0 # seconds to wait for daemon socket to appear
def is_enabled() -> bool:
"""Return True when daemon mode is requested via YA_JAVAC_DAEMON=1."""
return os.environ.get("YA_JAVAC_DAEMON", "0") == "1"
def compile(javac_bin: str, args: list) -> int:
"""
Compile via daemon. *args* are javac arguments WITHOUT the binary itself.
Stderr from the compiler is written to sys.stderr.
Falls back to subprocess on any daemon failure.
Returns the javac exit code.
"""
rc, stderr_data = compile_raw(javac_bin, args)
if stderr_data:
sys.stderr.buffer.write(stderr_data)
sys.stderr.flush()
return rc
class _ResourceFlagFallback(Exception):
"""Raised when a request carries a resource JVM flag (-Xss/-Xmx/-XX:) that
a running daemon cannot adopt, forcing a subprocess fallback for this node."""
class _DeniedProcessorFallback(Exception):
"""Raised when a request uses an annotation processor known to be UNSAFE in
the shared in-process daemon, forcing a subprocess fallback for this node."""
# Annotation processors that mutate JVM-global state (e.g. System.setProperty)
# WHILE compiling and read it back within the same javac round. In the shared
# daemon two such compilations run concurrently on one JVM and can observe each
# other's global mutation — a silent wrong-output race that no per-request
# snapshot/restore can fix (the value is set-and-read inside the same run, so a
# concurrent thread sees the wrong value mid-flight). Such processors MUST run
# in their own JVM, i.e. subprocess fallback.
#
# Evidence: io.micronaut...TypeElementVisitorProcessor calls System.setProperty
# (verified by bytecode inspection of micronaut-inject-java). Micronaut is
# always registered with explicit -processor class names in Arcadia (55+
# modules), but a denied processor can ALSO reach javac without an explicit
# -processor flag (via -processorpath / -classpath META-INF/services
# discovery), so we match BOTH the processor class names AND the contrib jar
# path that ships them.
_DENIED_PROCESSOR_CLASS_INFIXES = (
"io.micronaut.annotation.processing.",
)
_DENIED_PROCESSOR_PATH_INFIXES = (
"contrib/java/io/micronaut/micronaut-inject-java",
)
# javac flags whose VALUE is a processor class list (comma-separated).
_PROCESSOR_CLASS_FLAGS = ("-processor",)
# javac flags whose VALUE is a path that may host processors via service
# discovery (no explicit -processor needed).
_PROCESSOR_PATH_FLAGS = (
"-processorpath",
"--processor-path",
"--processor-module-path",
"-classpath",
"-cp",
"--class-path",
)
def _expand_arg_file(token: str) -> str:
"""Return the text of an @argfile (javac/-processorpath '@file.cplst'),
or '' if it cannot be read. Used only to scan for denied processor paths;
failure is non-fatal (we fall back conservatively elsewhere)."""
if not token.startswith("@"):
return token
path = token[1:]
try:
with open(path, "r") as f:
return f.read()
except Exception:
# Unreadable argfile on a processor path → be conservative: signal a
# match so the node falls back rather than risk running a denied AP.
return "\0__UNREADABLE_ARGFILE__"
def _uses_denied_processor(args: list) -> bool:
"""True if *args* (javac compiler args) reference a deny-listed annotation
processor through ANY channel: explicit -processor class list, or a
processor/class path (incl. @argfile) that hosts the denied processor jar.
-proc:none disables annotation processing entirely → never denied.
"""
# If annotation processing is turned off, nothing can run.
if any(a == "-proc:none" for a in args):
return False
i = 0
n = len(args)
while i < n:
a = args[i]
# Channel 1: explicit -processor <comma-separated classes>
flag_val = None
for f in _PROCESSOR_CLASS_FLAGS:
if a == f and i + 1 < n:
flag_val = args[i + 1]
i += 1
break
if a.startswith(f + "="):
flag_val = a[len(f) + 1:]
break
if flag_val is not None:
for cls in flag_val.split(","):
for infix in _DENIED_PROCESSOR_CLASS_INFIXES:
if infix in cls:
return True
i += 1
continue
# Channel 2/3: -processorpath / -classpath (+ @argfile) hosting the jar
path_val = None
for f in _PROCESSOR_PATH_FLAGS:
if a == f and i + 1 < n:
path_val = args[i + 1]
i += 1
break
if a.startswith(f + "="):
path_val = a[len(f) + 1:]
break
if path_val is not None:
haystack = _expand_arg_file(path_val) if path_val.startswith("@") else path_val
# An unreadable @argfile on a processor/class path could hide a
# denied processor jar — refuse conservatively (subprocess).
if "__UNREADABLE_ARGFILE__" in haystack:
return True
for infix in _DENIED_PROCESSOR_PATH_INFIXES:
if infix in haystack:
return True
i += 1
continue
# A bare @argfile (javac reads the whole command from it) may itself
# carry -processor / -processorpath lines — scan its contents too.
if a.startswith("@"):
text = _expand_arg_file(a)
if "__UNREADABLE_ARGFILE__" in text:
# Can't verify — but a bare top-level argfile holds the WHOLE
# command; refusing every node with one would disable the
# daemon broadly. Only the processor-path channels above force
# a conservative match; here we simply skip unreadable files.
i += 1
continue
for infix in _DENIED_PROCESSOR_CLASS_INFIXES + _DENIED_PROCESSOR_PATH_INFIXES:
if infix in text:
return True
i += 1
return False
def _route(launcher_flags: list) -> list:
"""Classify caller-identified *launcher_flags* into the semantic flagset.
Raises _ResourceFlagFallback if any launcher flag is a resource/perf flag
(or an unrecognized launcher flag) that cannot be applied to an already-
running daemon JVM and must not be silently dropped (e.g. -J-Xss128m for
lombok), so the caller falls back to a subprocess.
Returns the semantic flagset (possibly empty → default daemon).
"""
semantic, resource = _classify_launcher_flags(launcher_flags)
if resource:
raise _ResourceFlagFallback(
"request needs JVM launcher flags " + " ".join(resource)
)
return semantic
def _split_javac_launcher(args: list):
"""Split javac *args* into (launcher_flags, compile_args).
For the javac path, launcher flags are exactly the "-J..."-prefixed
arguments. Everything else is an ordinary javac compiler option and passes
straight through to COMPILER.run() (including bare --add-exports,
--enable-preview, --release N).
"""
launcher, compile_args = [], []
for a in args:
if a.startswith("-J"):
launcher.append(a)
else:
compile_args.append(a)
return launcher, compile_args
def compile_raw(javac_bin: str, args: list) -> tuple:
"""
Like compile() but returns (exit_code, stderr_bytes) without writing
to stderr, so the caller can post-process the output.
Falls back to subprocess on any daemon failure.
"""
try:
launcher_flags, compile_args = _split_javac_launcher(args)
if _uses_denied_processor(compile_args):
raise _DeniedProcessorFallback(
"request uses a daemon-unsafe annotation processor"
)
semantic_jvm = _route(launcher_flags)
return _via_daemon(javac_bin, compile_args, semantic_jvm)
except (_ResourceFlagFallback, _DeniedProcessorFallback) as e:
sys.stderr.write(f"[javac-daemon] subprocess fallback: {e}\n")
return _via_subprocess(javac_bin, args)
except Exception as e:
sys.stderr.write(f"[javac-daemon] falling back to subprocess: {e}\n")
return _via_subprocess(javac_bin, args)
def compile_and_jar(
javac_bin: str,
javac_args: list,
jar_output: str,
classes_dir: str,
srcs_jar_output: str = "",
sources_dir: str = "",
vcs_mf: str = "",
) -> tuple:
"""
Compile Java sources AND package the result into jar(s) in one daemon
round-trip, eliminating the two separate jar subprocess calls.
javac_args — args passed to javac (without the binary itself)
jar_output — path for the classes jar
classes_dir — javac -d directory; also the root for the classes jar
srcs_jar_output — path for the sources jar; "" to skip
sources_dir — root directory for the sources jar content
vcs_mf — path to a VCS manifest snippet; "" = cfM (no manifest)
Returns (exit_code, stderr_bytes).
Falls back to separate subprocess calls on any daemon failure.
"""
try:
# Only "-J..." javac args are launcher flags; everything else (incl.
# bare --add-exports/--enable-preview) is a compiler arg that passes
# through. The COMPILE_JAR header is fixed and never classified.
launcher_flags, compile_args = _split_javac_launcher(javac_args)
if _uses_denied_processor(compile_args):
raise _DeniedProcessorFallback(
"request uses a daemon-unsafe annotation processor"
)
semantic_jvm = _route(launcher_flags)
daemon_args = [
"__COMPILE_JAR__",
jar_output,
srcs_jar_output,
vcs_mf,
classes_dir,
sources_dir,
] + compile_args
return _via_daemon(javac_bin, daemon_args, semantic_jvm)
except (_ResourceFlagFallback, _DeniedProcessorFallback) as e:
sys.stderr.write(f"[javac-daemon] compile_and_jar subprocess fallback: {e}\n")
return _fallback_compile_and_jar(
javac_bin, javac_args, jar_output, classes_dir,
srcs_jar_output, sources_dir, vcs_mf,
)
except Exception as e:
sys.stderr.write(f"[javac-daemon] compile_and_jar falling back: {e}\n")
return _fallback_compile_and_jar(
javac_bin, javac_args, jar_output, classes_dir,
srcs_jar_output, sources_dir, vcs_mf,
)
def compile_kotlin(javac_bin: str, kotlin_compiler: str, kotlinc_args: list,
jvm_launcher_flags: list = None) -> tuple:
"""
Run the Kotlin compiler in-process inside the daemon.
kotlin_compiler — absolute path to kotlin-compiler(-embeddable).jar
kotlinc_args — kotlinc compiler arguments (post-'-jar'):
[-classpath cp] [-d out] [srcs...]
jvm_launcher_flags — the pre-'-jar' JVM launcher flags from the original
``java <flags> -jar kotlin-compiler.jar`` command line:
-D props, --enable-native-access, --sun-misc-unsafe-
memory-access, etc. These configure the kotlinc JVM,
not the compiler args.
Launcher flags are classified by _route:
* -D props and other semantic launcher flags are folded into the daemon
flagset, so a given Kotlin daemon only ever sees ONE set of -D values —
they become a per-daemon constant, which removes the cross-request
setProperty race (Issue G). -D props are still forwarded to the daemon
on the wire (it applies/restores them per slot), but every slot in that
daemon now sets the identical value.
* a resource launcher flag (-Xss/-Xmx/-XX:) forces a subprocess fallback.
Returns (exit_code, stderr_bytes).
Falls back to a subprocess ``java <flags> -jar kotlin_compiler`` call when
the daemon is unavailable or returns RC_NO_KOTLIN_SLOT (99).
"""
_RC_NO_KOTLIN_SLOT = 99
jvm_launcher_flags = jvm_launcher_flags or []
try:
semantic_jvm = _route(jvm_launcher_flags)
except _ResourceFlagFallback as e:
sys.stderr.write(f"[javac-daemon] compile_kotlin subprocess fallback: {e}\n")
return _fallback_compile_kotlin(
javac_bin, kotlin_compiler, kotlinc_args, jvm_launcher_flags)
# "-D" props must also reach the daemon so it applies them via
# System.setProperty before invoking kotlinc. Forward them on the wire (the
# daemon splits them out again) while keeping them in semantic_jvm so they
# also key/launch the right side-daemon. kotlinc_args are pure compiler args
# and pass through untouched.
d_props = [p for p in semantic_jvm if p.startswith("-D")]
daemon_args = ["__COMPILE_KOTLIN__", kotlin_compiler] + d_props + kotlinc_args
try:
rc, stderr = _via_daemon(javac_bin, daemon_args, semantic_jvm)
if rc == _RC_NO_KOTLIN_SLOT:
return _fallback_compile_kotlin(
javac_bin, kotlin_compiler, kotlinc_args, jvm_launcher_flags)
return rc, stderr
except Exception as e:
sys.stderr.write(f"[javac-daemon] compile_kotlin falling back to subprocess: {e}\n")
return _fallback_compile_kotlin(
javac_bin, kotlin_compiler, kotlinc_args, jvm_launcher_flags)
def _fallback_compile_kotlin(javac_bin: str, kotlin_compiler: str, kotlinc_args: list,
jvm_launcher_flags: list = None) -> tuple:
"""Subprocess fallback for compile_kotlin.
Reconstructs the original launch: java <launcher flags> -jar kc.jar <args>.
"""
java_bin = os.path.join(_jdk_root(javac_bin), "bin", "java")
jvm_launcher_flags = jvm_launcher_flags or []
cmd = [java_bin] + jvm_launcher_flags + ["-jar", kotlin_compiler] + kotlinc_args
p = subprocess.Popen(cmd, stderr=subprocess.PIPE)
_, err = p.communicate()
return p.returncode, err
def _fallback_compile_and_jar(
javac_bin, javac_args, jar_output, classes_dir,
srcs_jar_output, sources_dir, vcs_mf,
):
"""Subprocess fallback for compile_and_jar."""
import subprocess as _sp
# Compile
rc, err = _via_subprocess(javac_bin, javac_args)
if rc != 0:
return rc, err
jar_bin = os.path.join(_jdk_root(javac_bin), "bin", "jar")
def run_jar(out, base_dir, mf):
if mf:
return _sp.run(
[jar_bin, "cfm", out, mf, "."],
cwd=base_dir, capture_output=True,
)
return _sp.run(
[jar_bin, "cfM", out, "."],
cwd=base_dir, capture_output=True,
)
r = run_jar(jar_output, classes_dir, vcs_mf or None)
if r.returncode != 0:
return r.returncode, r.stderr
if srcs_jar_output:
r = run_jar(srcs_jar_output, sources_dir, vcs_mf or None)
if r.returncode != 0:
return r.returncode, r.stderr
return 0, err
# ---------------------------------------------------------------------------
# JVM-launcher-flag classification and flagset routing (Issues B and G)
# ---------------------------------------------------------------------------
#
# Background. The daemon is a long-lived JVM launched once by _start_daemon().
# JVM *launcher* flags — those that configure the compiler JVM process itself —
# are fixed at daemon launch time and CANNOT be changed per request. A request
# that needs the JVM started with a flag the running daemon lacks therefore
# cannot be served by that daemon.
#
# CRITICAL DISTINCTION — launcher flags vs. compiler args. Only a subset of the
# build's flags are JVM launcher flags; the rest are ordinary *compiler*
# arguments that pass straight through to COMPILER.run() / kotlinc and need no
# special handling. The two cannot be told apart by spelling alone, so the
# CALLER tags which arguments are launcher flags:
#
# * javac path: launcher flags are exactly the "-J..."-prefixed arguments
# (javac forwards "-J<x>" to its JVM as "<x>"). Everything else — including
# bare "--add-exports=...", "--enable-preview", "--release N" — is a real
# javac compiler option (verified: COMPILER.run() honors --add-exports and
# --enable-preview in-process; "--add-opens has no effect at compile time"
# and is launcher-only). Bare flags MUST pass through untouched.
#
# * kotlin path: launcher flags are exactly the pre-"-jar" arguments
# (java <launcher flags> -jar kotlin-compiler.jar <kotlinc args>). These
# include -D props and --enable-native-access / --sun-misc-unsafe-memory-
# access (java.conf:827,1592). Post-"-jar" args are kotlinc compiler args.
#
# Given the launcher flags, we classify each into three buckets:
#
# * SEMANTIC — changes compilation behavior/output, so MUST be honored by
# routing the request to a dedicated side-daemon launched with exactly those
# flags. The semantic flagset is folded into the socket name (a hash) so
# same-flagset requests share one warm JVM and different flagsets get
# isolated JVMs. This also kills the Kotlin "-D" cross-request race
# (Issue G): "-D" props become a per-daemon constant, so the daemon's
# setProperty/restore window can never observe a foreign value.
#
# * RESOURCE (-Xss/-Xmx/-XX:...) — perf/limit only; EXCLUDED from the flagset
# hash to avoid daemon proliferation. A running JVM can't adopt a new stack
# size, so a request carrying one (e.g. -J-Xss128m for lombok, which
# reproducibly fails with a smaller stack) falls back to a subprocess rather
# than silently dropping the flag.
#
# * IGNORABLE — safe to drop for in-process compilation (see below).
# Semantic launcher flags that take a VALUE. The value may be attached
# ("--flag=V") or be the following argument ("--flag" "V"). Both forms occur
# (error-prone emits "-J--add-opens" "-Jjdk.compiler/...=ALL-UNNAMED").
_SEMANTIC_VALUE_FLAGS = (
"--add-opens",
"--add-exports",
"--add-modules",
"--add-reads",
"--patch-module",
)
# Semantic launcher flags with no separate value (standalone or "=value").
_SEMANTIC_BARE_FLAGS = (
"--enable-native-access",
"--sun-misc-unsafe-memory-access",
"--enable-preview",
)
# Resource/perf launcher flags: NOT hashed; presence forces a subprocess
# fallback (a running JVM can't adopt a new stack/heap size).
_RESOURCE_JVM_PREFIXES = (
"-Xss",
"-Xmx",
"-Xms",
"-Xmn",
"-XX:",
)
# Launcher flags safe to DROP silently for in-process compilation: they tune the
# process-wide runtime, not the compilation output, and the daemon manages its
# own equivalent. Checked BEFORE the resource list so they don't force fallback.
#
# -XX:ActiveProcessorCount is the build's default JAVAC_OPTS (java.conf:1654),
# present on EVERY javac node as -J-XX:ActiveProcessorCount=N. It caps the
# compiler's fork/join parallelism for subprocess javac; in the daemon the
# parallelism is governed process-wide by the daemon's own JVM, so this
# per-request value is irrelevant and dropping it is correct. Treating it as a
# hard fallback would disable the daemon for every node.
#
# -Djava.io.tmpdir is injected on EVERY java/javac node by setup_java_tmpdir.py
# (-J-Djava.io.tmpdir=$TMPDIR for javac, -Djava.io.tmpdir=$TMPDIR for kotlin),
# and $TMPDIR is the per-NODE build sandbox (…/0000aN/r3tmp), so its value is
# unique per node. It only selects where the compiler JVM writes scratch temp
# files (not compilation output), so it is NOT semantic. It must be IGNORED
# (dropped, not folded into the flagset): folding it would give every node a
# distinct flagset hash and thus its own cold side-daemon, defeating the daemon
# entirely. The daemon JVM uses its own startup tmpdir for scratch, which is
# correct. Checked before the "-D" semantic branch below so it wins.
_IGNORABLE_JVM_PREFIXES = (
"-XX:ActiveProcessorCount",
"-Djava.io.tmpdir",
)
def _strip_J(flag: str) -> str:
"""Strip a leading javac '-J' launcher prefix, if present.
javac forwards '-J<x>' to its JVM as '<x>'. Kotlin launcher flags arrive
without the '-J' prefix already, so this is a no-op for them.
"""
return flag[2:] if flag.startswith("-J") else flag
def _classify_launcher_flags(launcher_flags: list):
"""Classify a list of JVM *launcher* flags into (semantic, resource).
*launcher_flags* must contain ONLY flags the caller has already identified as
JVM launcher flags (javac '-J...'; kotlin pre-'-jar' args). Ordinary
compiler arguments must NOT be passed here — they belong in the compile-args
stream and pass straight through to the compiler.
Returns (semantic, resource):
semantic — flags to apply to the side-daemon's `java` launch, with the
'-J' prefix stripped and values normalized to the attached
"--flag=value" form (single token; survives the flagset hash
and the `java` launch). Includes "-D..." props.
resource — perf/limit flags; non-empty ⇒ caller must subprocess.
(ignorable flags are dropped; unrecognized launcher flags are treated as
resource — conservative subprocess fallback — so a bad flag is never
silently applied or silently lost.)
"""
semantic, resource = [], []
i = 0
n = len(launcher_flags)
while i < n:
bare = _strip_J(launcher_flags[i])
# Value-taking semantic flag, attached or two-argument form.
matched_value_flag = None
for p in _SEMANTIC_VALUE_FLAGS:
if bare == p or bare.startswith(p + "="):
matched_value_flag = p
break
if matched_value_flag is not None:
if "=" in bare:
semantic.append(bare)
i += 1
elif i + 1 < n:
value = _strip_J(launcher_flags[i + 1])
semantic.append(f"{matched_value_flag}={value}")
i += 2
else:
resource.append(bare) # dangling — subprocess, don't break java
i += 1
continue
# Ignorable runtime-tuning flags (e.g. -Djava.io.tmpdir,
# -XX:ActiveProcessorCount) are dropped — checked BEFORE the generic
# "-D" branch so per-node-varying props never key the flagset.
ignored = False
for p in _IGNORABLE_JVM_PREFIXES:
if bare.startswith(p):
ignored = True
break
if ignored:
i += 1
continue
if bare.startswith("-D"):
semantic.append(bare)
i += 1
continue
bare_semantic = False
for p in _SEMANTIC_BARE_FLAGS:
if bare == p or bare.startswith(p + "="):
semantic.append(bare)
bare_semantic = True
break
if bare_semantic:
i += 1
continue
# Everything else among launcher flags (recognized resource prefixes and
# any unrecognized launcher flag) → resource ⇒ subprocess fallback.
resource.append(bare)
i += 1
return semantic, resource
def _flagset_hash(semantic_jvm: list) -> str:
"""Stable hash of the semantic JVM flagset; "" for the empty set.
The empty set returns "" so the default (shared) daemon keeps its current
socket name and behavior — no change for the overwhelmingly common case.
"""
if not semantic_jvm:
return ""
canon = "\0".join(sorted(semantic_jvm)).encode("utf-8")
return hashlib.sha256(canon).hexdigest()[:12]
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _jdk_root(javac_bin: str) -> str:
return os.path.dirname(os.path.dirname(os.path.abspath(javac_bin)))
def _jdk_major_version(javac_bin: str) -> int:
"""Return the major JDK version (e.g. 11, 17, 21) or 0 on failure.
Reads $JDK_ROOT/release to avoid spawning a subprocess."""
try:
release = os.path.join(_jdk_root(javac_bin), "release")
with open(release) as f:
for line in f:
if line.startswith("JAVA_VERSION="):
# JAVA_VERSION="17.0.x" or JAVA_VERSION="11.0.x"
ver = line.split("=", 1)[1].strip().strip('"')
return int(ver.split(".")[0])
except Exception:
pass
return 0
def _resource_id(javac_bin: str) -> str:
"""
Stable per-JDK identifier.
In ya build, JDK_RESOURCE / EXTERNAL_JAVA_JDK_RESOURCE resolve to paths
like /…/.ya/tools/v4/<resource_id>/, so basename gives a unique value
that changes whenever the JDK toolchain is updated.
"""
return os.path.basename(_jdk_root(javac_bin))
def _socket_path(javac_bin: str, semantic_jvm: list = None) -> str:
"""Socket path for the daemon serving this JDK and semantic flagset.
Lives in /tmp (not the cache dir) because Unix domain socket paths are
limited to 104 chars on macOS, and SHALLOW_ROOT can be too deep.
The empty flagset (the common case) yields the original socket name, so
the shared default daemon is unchanged. A non-empty semantic flagset
appends a hash suffix, routing the request to a dedicated side-daemon
that was launched with exactly those JVM flags.
"""
uid = os.getuid()
rid = _resource_id(javac_bin)
jh = _jar_hash()
fh = _flagset_hash(semantic_jvm or [])
name = f"javac-daemon-{uid}-{rid}-{jh}-{fh}.sock" if fh else f"javac-daemon-{uid}-{rid}-{jh}.sock"
return os.path.join("/tmp", name)
def _jar_hash() -> str:
"""SHA-256 of the committed jar bytes, truncated to 16 hex chars.
Using the jar content as the cache-dir key has two properties:
- **Freshness (Issue H):** any change to JavacDaemon.java (rebuilt and
re-committed) produces a different hash → a new cache dir → the updated
jar is always extracted. No stale-jar problem.
- **Safe cross-user sharing (Issue I):** the directory name is
unpredictable without knowing the jar bytes, so a pre-planted jar
attack is infeasible. Moreover, _ensure_jar overwrites the jar
unconditionally (see below), so a pre-planted file is harmless.
Different users building with the same daemon version share one warm
JVM without any UID-scoping needed.
"""
return hashlib.sha256(_read_jar_bytes()).hexdigest()[:16]
def _cache_dir() -> str:
# Use YA_JAVAC_DAEMON_HOME (set by the build system to SHALLOW_ROOT) so the
# dir — and daemon logs — survive the per-node sandbox cleanup that resets
# TMPDIR after each build node. Fall back to /tmp for manual/standalone use.
# A subdir keyed on the jar hash isolates daemon versions and provides the
# content-addressed cache property (see _jar_hash() for rationale).
base = os.environ.get("YA_JAVAC_DAEMON_HOME", "/tmp")
d = os.path.join(base, "javac-daemon", _jar_hash())
os.makedirs(d, exist_ok=True)
return d
def _ensure_jar() -> str:
"""Copy the committed daemon jar into the cache dir, always overwriting;
return its path.
Overwriting unconditionally (rather than only on absence) ensures that
even if an attacker pre-created the directory, the legitimate jar is
written atomically before the daemon is launched. It also guarantees
freshness in the unlikely event the hash collides across versions.
The write is atomic (os.replace on the same filesystem), so concurrent
callers racing here are safe: the last writer wins, and all get the
same bytes.
"""
jar_data = _read_jar_bytes()
jar_path = os.path.join(_cache_dir(), "JavacDaemon.jar")
tmp = jar_path + ".tmp"
with open(tmp, "wb") as f:
f.write(jar_data)
os.replace(tmp, jar_path) # atomic on same filesystem
return jar_path
def _start_daemon(javac_bin: str, semantic_jvm: list = None):
"""Launch the daemon fully detached and wait until the socket appears.
*semantic_jvm* are JVM flags (e.g. --add-opens, -Dfoo=bar) applied to the
daemon's `java` launch. They define this daemon's flagset; the socket name
encodes their hash so requests with the same flagset reuse this daemon.
"""
semantic_jvm = semantic_jvm or []
jar_path = _ensure_jar()
sock_path = _socket_path(javac_bin, semantic_jvm)
java_bin = os.path.join(_jdk_root(javac_bin), "bin", "java")
cache_dir = _cache_dir()
# Per-flagset log files so concurrent side-daemons don't clobber each other.
fh = _flagset_hash(semantic_jvm)
suffix = f"-{fh}" if fh else ""
out_log = os.path.join(cache_dir, f"daemon{suffix}.out")
err_log = os.path.join(cache_dir, f"daemon{suffix}.err")
with open(out_log, "w") as fout, open(err_log, "w") as ferr:
subprocess.Popen(
[java_bin] + semantic_jvm + ["-cp", jar_path, "JavacDaemon"],
stdin=subprocess.DEVNULL,
stdout=fout,
stderr=ferr,
start_new_session=True, # detach: don't block the parent shell
env={**os.environ, "JAVAC_DAEMON_SOCKET": sock_path},
)
deadline = time.monotonic() + DAEMON_START_TIMEOUT
while time.monotonic() < deadline:
if os.path.exists(sock_path):
return
time.sleep(0.05)
raise RuntimeError(
f"JavacDaemon did not start within {DAEMON_START_TIMEOUT}s "
f"(log: {err_log})"
)
def _send(sock_path: str, args: list) -> tuple:
"""Send a compile request; return (exit_code, stderr_bytes)."""
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
s.connect(sock_path)
chunks = [struct.pack(">I", len(args))]
for arg in args:
b = arg.encode("utf-8")
chunks.append(struct.pack(">I", len(b)))
chunks.append(b)
s.sendall(b"".join(chunks))
s.shutdown(socket.SHUT_WR)
def recv_exact(n):
buf = bytearray()
while len(buf) < n:
chunk = s.recv(n - len(buf))
if not chunk:
raise EOFError(
f"Daemon closed connection after {len(buf)}/{n} bytes"
)
buf.extend(chunk)
return bytes(buf)
exit_code = struct.unpack(">I", recv_exact(4))[0]
stderr_len = struct.unpack(">I", recv_exact(4))[0]
stderr_data = recv_exact(stderr_len)
return exit_code, stderr_data
def _ensure_daemon(javac_bin: str, semantic_jvm: list = None):
"""Start the daemon if not running; restart if it stopped responding."""
semantic_jvm = semantic_jvm or []
# Daemon jar requires JDK 17+; skip silently for older JDKs.
if _jdk_major_version(javac_bin) < 17:
raise RuntimeError(
f"javac-daemon requires JDK 17+; skipping for {javac_bin}"
)
sock_path = _socket_path(javac_bin, semantic_jvm)
if os.path.exists(sock_path):
try:
_send(sock_path, ["-version"])
return # alive
except Exception:
try:
os.unlink(sock_path)
except OSError:
pass
_start_daemon(javac_bin, semantic_jvm)
def _via_daemon(javac_bin: str, args: list, semantic_jvm: list = None) -> tuple:
"""Send *args* to the daemon for this JDK and semantic flagset.
*semantic_jvm* selects (and, if needed, launches) the side-daemon whose JVM
was started with those flags. *args* must already have had its JVM flags
removed (they live in semantic_jvm now); only compiler args remain.
"""
semantic_jvm = semantic_jvm or []
_ensure_daemon(javac_bin, semantic_jvm)
sock_path = _socket_path(javac_bin, semantic_jvm)
# Retry a few times for transient ECONNREFUSED / ENOENT that can occur
# during parallel daemon startup when multiple clients race to start it.
_RETRIES = 5
for attempt in range(_RETRIES):
try:
return _send(sock_path, args)
except (ConnectionRefusedError, FileNotFoundError):
if attempt == _RETRIES - 1:
raise
time.sleep(0.1 * (attempt + 1))
# Re-ensure in case the socket was replaced by a new daemon instance.
_ensure_daemon(javac_bin, semantic_jvm)
sock_path = _socket_path(javac_bin, semantic_jvm)
def _via_subprocess(javac_bin: str, args: list) -> tuple:
p = subprocess.Popen([javac_bin] + args, stderr=subprocess.PIPE)
_, err = p.communicate()
return p.returncode, err
|